Action Games
Frequent position updates with client-side interpolation for smooth movement.
When to Use
- Shooters
- Platformers
- Racing games
- Fighting games
- Any game with real-time movement
The Pattern
import { connect } from '@watchtower-sdk/core'
const room = await connect('action-game')
const players = {}
const LERP = 0.15
const SEND_RATE = 50 // 20Hz
// My player
const myPlayer = { x: 100, y: 100, vx: 0, vy: 0, anim: 'idle' }
// Send position updates at fixed rate
setInterval(() => {
room.broadcast({
type: 'pos',
x: myPlayer.x,
y: myPlayer.y,
vx: myPlayer.vx,
vy: myPlayer.vy,
anim: myPlayer.anim
})
}, SEND_RATE)
// Receive and store target positions
room.on('message', (from, data, meta) => {
if (data.type === 'pos') {
if (!players[from]) {
players[from] = { x: data.x, y: data.y, targetX: data.x, targetY: data.y }
}
players[from].targetX = data.x
players[from].targetY = data.y
players[from].vx = data.vx
players[from].vy = data.vy
players[from].anim = data.anim
players[from].lastUpdate = meta.serverTime
}
if (data.type === 'shoot') {
spawnBullet(from, data.x, data.y, data.angle)
}
if (data.type === 'hit') {
if (data.target === room.playerId) {
myPlayer.health -= data.damage
}
}
})
room.on('leave', (player) => {
delete players[player.id]
})
// Game loop - interpolate other players
function update() {
// Update my player based on input
updateMyPlayer()
// Interpolate other players toward their targets
for (const p of Object.values(players)) {
p.x += (p.targetX - p.x) * LERP
p.y += (p.targetY - p.y) * LERP
}
requestAnimationFrame(update)
}
update()
// Shooting
function shoot(angle) {
room.broadcast({ type: 'shoot', x: myPlayer.x, y: myPlayer.y, angle })
spawnBullet(room.playerId, myPlayer.x, myPlayer.y, angle)
}Key Points
- Fixed send rate — 20Hz is good balance of smoothness vs bandwidth
- Client-side interpolation — lerp toward received positions
- Separate state types — positions vs events (shots, hits)
- Include velocity — helps with prediction if needed
Interpolation Deep Dive
Simple lerping works for most games:
const LERP = 0.15 // 0.1 = smooth, 0.3 = snappy
function interpolate() {
for (const p of Object.values(players)) {
p.x += (p.targetX - p.x) * LERP
p.y += (p.targetY - p.y) * LERP
}
}For more accuracy, use server timestamps to interpolate between snapshots:
const INTERP_DELAY = 100 // Render 100ms in the past
room.on('message', (from, data, meta) => {
if (!players[from]) players[from] = { snapshots: [] }
players[from].snapshots.push({
time: meta.serverTime,
x: data.x,
y: data.y
})
// Keep last 10 snapshots
if (players[from].snapshots.length > 10) {
players[from].snapshots.shift()
}
})
function interpolate() {
const renderTime = Date.now() - INTERP_DELAY
for (const p of Object.values(players)) {
const snaps = p.snapshots
if (snaps.length < 2) continue
// Find surrounding snapshots
let before = snaps[0], after = snaps[1]
for (let i = 0; i < snaps.length - 1; i++) {
if (snaps[i].time <= renderTime && snaps[i + 1].time >= renderTime) {
before = snaps[i]
after = snaps[i + 1]
break
}
}
// Interpolate
const t = (renderTime - before.time) / (after.time - before.time)
p.x = before.x + (after.x - before.x) * t
p.y = before.y + (after.y - before.y) * t
}
}Hit Detection
For casual games, client-side hit detection works fine:
function checkBulletHits(bullet) {
for (const [id, player] of Object.entries(players)) {
const dx = bullet.x - player.x
const dy = bullet.y - player.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < HIT_RADIUS) {
room.broadcast({ type: 'hit', target: id, damage: 10 })
return true
}
}
return false
}Full Shooter Example
import { connect } from '@watchtower-sdk/core'
const room = await connect()
const players = {}
const bullets = []
const myPlayer = {
x: Math.random() * 800,
y: Math.random() * 600,
angle: 0,
health: 100
}
// Send position
setInterval(() => {
room.broadcast({
type: 'pos',
x: myPlayer.x,
y: myPlayer.y,
angle: myPlayer.angle
})
}, 50)
room.on('message', (from, data) => {
if (data.type === 'pos') {
if (!players[from]) players[from] = {}
players[from].targetX = data.x
players[from].targetY = data.y
players[from].angle = data.angle
if (players[from].x === undefined) {
players[from].x = data.x
players[from].y = data.y
}
}
if (data.type === 'shoot') {
bullets.push({
x: data.x, y: data.y,
vx: Math.cos(data.angle) * 10,
vy: Math.sin(data.angle) * 10,
owner: from
})
}
if (data.type === 'hit' && data.target === room.playerId) {
myPlayer.health -= data.damage
if (myPlayer.health <= 0) respawn()
}
})
room.on('leave', (p) => delete players[p.id])
function update() {
// Input
if (keys.w) myPlayer.y -= 5
if (keys.s) myPlayer.y += 5
if (keys.a) myPlayer.x -= 5
if (keys.d) myPlayer.x += 5
myPlayer.angle = Math.atan2(mouseY - myPlayer.y, mouseX - myPlayer.x)
// Interpolate others
for (const p of Object.values(players)) {
p.x += (p.targetX - p.x) * 0.15
p.y += (p.targetY - p.y) * 0.15
}
// Update bullets
for (const b of bullets) {
b.x += b.vx
b.y += b.vy
// Check hits...
}
requestAnimationFrame(update)
}
update()Next Steps
- Add health bars
- Add respawn logic
- Add different weapons
- Track scores with host authority