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