games / launchpad

I built a mobile web-based vertical platformer game, Launchpad.

The game models a startup journey. You pilot a rocket upward and bounce off platforms. Normal platforms break after two bounces and show a startup pitfall message. Boost platforms carry the IVP logo and add bounce force. If you miss a platform and fall, the game ends.

Architecture

The architecture is the same as Duck Duck Bay: a Go game engine compiled to WebAssembly, a Go HTTP server for static assets and templates, and vanilla HTML, CSS, and JavaScript on the client.

Physics

All physics constants live in Go:

const (
    Gravity      = 0.3
    BounceForce  = -12.0
    BoostForce   = -18.0
    MoveForce    = 0.8
    MaxVelocityX = 8.0
    Friction     = 0.98
)

Gravity pulls the rocket down each tick. A bounce sets vertical velocity to BounceForce, or BoostForce on a boost platform. Friction applies each frame, so the rocket drifts and decelerates.

The rocket bounces off screen edges:

if g.Rocket.X < 0 {
    g.Rocket.X = 0
    g.Rocket.VX = -g.Rocket.VX
} else if g.Rocket.X > CanvasWidth-g.Rocket.Width {
    g.Rocket.X = CanvasWidth - g.Rocket.Width
    g.Rocket.VX = -g.Rocket.VX
}

Platform decay

A normal platform breaks on its second bounce. That bounce still propels the rocket upward:

p.BounceCount++
if p.BounceCount >= 2 {
    p.Broken = true
    g.SoundEvents = append(g.SoundEvents, SoundBreak)
    g.PitfallMsg = PitfallMessages[g.rng.IntN(len(PitfallMessages))]
    g.PitfallTimer = 120 // ~2 seconds at 60fps
}

Boost platforms never break.

Pitfall messages

The pitfall messages use startup and venture capital themes:

var PitfallMessages = []string{
    "Surprise AWS bill",
    "Claude did it in one prompt",
    "Competitor raised $100M",
    "Runway down to 3 months",
    "Series A fell through",
    "Roasted on Hacker News",
    "It's always DNS",
    "CAC > LTV",
    // ... 40+ more
}

Platform reachability

The game limits the horizontal gap between consecutive platforms, so every platform is reachable:

const MaxHorizontalGap = 250

prevX := g.Platforms[len(g.Platforms)-1].X
minX := prevX - MaxHorizontalGap
maxX := prevX + MaxHorizontalGap

The maximum bounce height is v²/(2g) = 12²/(2×0.3) = 240px. Vertical gaps stay between 60px and 100px.

Background gradient

The background color shifts with altitude:

func calculateBackgroundColor(altitude int) string {
    if altitude < 1000 {
        t := float64(altitude) / 1000
        return lerpColor(0x3d5c3d, 0x87ceeb, t) // Earth → Sky
    } else if altitude < 5000 {
        t := float64(altitude-1000) / 4000
        return lerpColor(0x87ceeb, 0x1a1a4e, t) // Sky → Atmosphere
    } else if altitude < 10000 {
        t := float64(altitude-5000) / 5000
        return lerpColor(0x1a1a4e, 0x0a0a1a, t) // Atmosphere → Space
    }
    return "#0a0a1a"
}

Stars fade in above altitude 4000 and scroll at 10% of the camera speed.

Synthesized audio

The Web Audio API generates all sound from oscillators and noise. Go emits sound event strings ("bounce", "boost", "break") and JavaScript maps them to synthesizer calls:

Above altitude 4000, a drone of two detuned sine waves (55 Hz and 55.5 Hz) fades in. Its volume and pitch rise with altitude:

const droneVolume = Math.min(0.06, ((altitude - 4000) / 6000) * 0.06);

Fixed timestep

The game loop uses a fixed timestep, so physics do not depend on the display refresh rate:

const FIXED_DT = 1000 / 60; // 60 ticks per second

function gameLoop(currentTime) {
  const deltaTime = Math.min(currentTime - lastTime, 100);
  lastTime = currentTime;
  accumulator += deltaTime;

  while (accumulator >= FIXED_DT) {
    tick();
    accumulator -= FIXED_DT;
  }

  const state = JSON.parse(getState());
  render(state);
  requestAnimationFrame(gameLoop);
}

On a 120 Hz display, two render frames share one tick. The 100ms cap on deltaTime stops a burst of ticks after the tab loses focus.

Play

Play the game at launchpad.ivp.com.

← All articles