Build Asteroids with ClojureScript & Scittle

Create a classic Asteroids arcade game with physics simulation, collision detection, canvas graphics, and retro sound effects - now with mobile touch controls! All running in the browser with zero build tools!
Author
Published

November 9, 2025

Keywords

asteroids-game, canvas-graphics, physics-simulation, collision-detection, vector-graphics, game-loop, keyboard-controls, touch-controls, mobile-gaming, virtual-joystick, performance-optimization, retro-gaming, arcade-classics, web-audio-api, sound-synthesis, retro-sound-effects

Build Asteroids with ClojureScript & Scittle

About This Project

Remember the golden age of arcade games? Today, I’ll show you how to recreate one of the most iconic games of all time - Asteroids - using ClojureScript and Scittle, running entirely in your browser without any build tools!

This is part of my ongoing exploration of browser-native development with Scittle. Check out my previous articles in this series:

This project demonstrates advanced game development concepts including physics simulation, collision detection, and canvas graphics - all achievable without complex tooling!

What We’re Building

We’ll create a complete Asteroids game featuring:

  • Physics-based spaceship with momentum and inertia
  • Destructible asteroids that split into smaller pieces
  • Hyperspace jump with a risky twist (10% chance of destruction!)
  • UFO enemies that hunt and shoot at you
  • Particle effects for explosions and destruction
  • Retro sound effects using Web Audio API for authentic arcade audio
  • Progressive difficulty with increasing asteroid counts
  • High score tracking to compete against yourself
  • Canvas-based vector graphics for that authentic retro feel
  • Mobile touch controls with virtual joystick and action buttons
  • Performance optimizations to handle intense gameplay

All of this with keyword arguments for clean, readable code!

The Journey: From Desktop to Mobile

A Kid’s Request

After building the initial desktop version, my kids wanted to play Asteroids on their phones. The problem? No physical keyboard! This led me to add comprehensive mobile support with touch controls.

The challenge was creating an intuitive control scheme that matched the precision of keyboard controls while working naturally on a touchscreen. The solution: a virtual joystick for ship control and dedicated buttons for firing and hyperspace jumps.

The Performance Crisis

During testing, my kids discovered a critical bug: rapid firing would cause asteroids to split exponentially, creating hundreds of objects that made the game completely unplayable. The browser would freeze, and the game became a slideshow of particle effects and asteroid outlines.

This real-world feedback led to important performance improvements that made the game smooth and enjoyable on both desktop and mobile devices.

Why Scittle for Arcade Games?

Zero Build Configuration

Traditional ClojureScript game development requires:

  • Setting up shadow-cljs or figwheel
  • Configuring webpack for assets
  • Managing dependencies and builds
  • Dealing with hot reload complications

With Scittle, you write pure ClojureScript that runs immediately. Perfect for:

  • Prototyping game mechanics
  • Learning game development concepts
  • Building retro-style browser games
  • Teaching programming through games

Instant Feedback Loop

Changes appear the moment you save. No watching compile output, no build errors - just code, save, and play!

Educational Value

This approach teaches:

  • Game state management with atoms
  • Physics simulation (velocity, acceleration, friction)
  • Collision detection algorithms
  • Canvas API and vector graphics
  • Game loop architecture
  • Functional game development patterns

Game Architecture

The game follows a clean, functional architecture:

graph TD A[Game State Atom] --> B[Game Loop] B --> C[Update Physics] B --> D[Check Collisions] B --> E[Handle Input] C --> F[Update Ship] C --> G[Update Asteroids] C --> H[Update Bullets] C --> I[Update UFOs] D --> J[Bullet-Asteroid] D --> K[Ship-Asteroid] D --> L[Bullet-UFO] E --> M[Keyboard Events] M --> N[Rotate/Thrust/Fire] F --> O[Canvas Rendering] G --> O H --> O I --> O O --> B

Core Game Systems

1. State Management with Reagent Atoms

All game state lives in a single atom for predictable updates:

(def game-state
  (r/atom {:ship {:x 400 :y 300 :vx 0 :vy 0 :angle 0
                  :thrusting false :invulnerable 0}
           :bullets []
           :asteroids []
           :ufos []
           :particles []
           :score 0
           :high-score 0
           :lives 3
           :level 1
           :game-status :ready
           :hyperspace-cooldown 0
           :frame-count 0
           :ufo-timer 0}))

2. Physics Simulation

The game implements realistic physics with keyword arguments:

;; Wrap-around screen boundaries
(defn wrap-position
  [& {:keys [value max-val]}]
  (cond
    (< value 0) (+ max-val value)
    (> value max-val) (- value max-val)
    :else value))

;; Apply thrust with maximum velocity limits
(let [new-vx (if thrusting
               (min max-velocity
                    (max (- max-velocity)
                         (+ vx (* thrust-power (Math/cos angle)))))
               (* vx friction))]
  ...)

3. Vector Graphics with Canvas

Ships and asteroids are drawn using vector paths:

(defn draw-ship
  [& {:keys [ctx x y angle thrusting invulnerable]}]
  (.save ctx)
  (.translate ctx x y)
  (.rotate ctx angle)
  (set! (.-strokeStyle ctx) "#FFFFFF")
  (.beginPath ctx)
  (.moveTo ctx 0 (- ship-size))
  (.lineTo ctx (- ship-size) ship-size)
  (.lineTo ctx 0 (/ ship-size 2))
  (.lineTo ctx ship-size ship-size)
  (.closePath ctx)
  (.stroke ctx)
  (.restore ctx))

Key Game Mechanics

sequenceDiagram participant P as Player participant G as Game Loop participant C as Collision System participant R as Renderer P->>G: Start Game G->>G: Init Asteroids loop Every Frame P->>G: Keyboard Input G->>G: Update Ship Physics G->>G: Update Bullets G->>G: Update Asteroids G->>C: Check Collisions alt Bullet Hits Asteroid C->>G: Split Asteroid C->>G: Add Score C->>G: Create Particles else Ship Hits Asteroid C->>G: Lose Life C->>G: Reset Ship end G->>R: Draw Everything R-->>P: Display Frame end

Asteroid Creation and Splitting

Asteroids are procedurally generated with irregular shapes:

(defn create-asteroid-shape
  [& {:keys [size]}]
  (let [num-vertices (+ 8 (rand-int 5))
        angle-step (/ (* 2 Math/PI) num-vertices)]
    (vec (for [i (range num-vertices)]
           (let [angle (* i angle-step)
                 radius (+ (* size 0.8) (* (rand) size 0.4))]
             {:x (* radius (Math/cos angle))
              :y (* radius (Math/sin angle))})))))

(defn split-asteroid
  [& {:keys [asteroid]}]
  (let [{:keys [x y size-type]} asteroid]
    (case size-type
      :large (for [_ (range 2)]
               (create-asteroid :x (+ x (- (rand-int 20) 10))
                                :y (+ y (- (rand-int 20) 10))
                                :size-type :medium))
      :medium (for [_ (range 2)]
                (create-asteroid :x (+ x (- (rand-int 10) 5))
                                 :y (+ y (- (rand-int 10) 5))
                                 :size-type :small))
      :small [])))

Collision Detection

Simple but effective circle-based collision detection:

(defn distance
  [& {:keys [x1 y1 x2 y2]}]
  (Math/sqrt (+ (* (- x2 x1) (- x2 x1))
                (* (- y2 y1) (- y2 y1)))))

(defn check-collision
  [& {:keys [obj1 obj2 radius1 radius2]}]
  (< (distance :x1 (:x obj1) :y1 (:y obj1)
               :x2 (:x obj2) :y2 (:y obj2))
     (+ radius1 radius2)))

Hyperspace Jump - The Risky Escape

One of the most iconic features! Teleport to safety… or die trying.

Code Review Improvement

After sharing this code on Slack, Erik Assum provided excellent feedback:

“Very cool! Looking at the code, in the hyperspace! you have multiple swap!s on your gamestate atom. Probs doesn’t make a difference here, but it does defeat the purpose of an atom, because you’re now susceptible to races (at least in a multi-threaded env), and if you have listeners to the atom state, they’ll get more updates than what they bargained for.”

The Problem: Multiple swap! calls meant:

  • Multiple state updates (5-7 separate modifications)
  • Race condition risk in multi-threaded environments
  • Multiple Reagent re-renders instead of one
  • Listeners notified multiple times per hyperspace jump

The Solution: Combine all updates into a single swap!:

(defn hyperspace!
  "Hyperspace jump with risk"
  []
  (when (<= (:hyperspace-cooldown @game-state) 0)
    (play-hyperspace-sound) ; Sound effect added!
    (let [new-x (rand-int canvas-width)
          new-y (rand-int canvas-height)
          died? (< (rand) 0.1)]
      (swap! game-state
             (fn [state]
               (-> state
                   ;; Teleport ship
                   (assoc-in [:ship :x] new-x)
                   (assoc-in [:ship :y] new-y)
                   (assoc-in [:ship :vx] 0)
                   (assoc-in [:ship :vy] 0)
                   (assoc :hyperspace-cooldown hyperspace-cooldown)
                   ;; Conditionally handle death (10% chance!)
                   (#(if died?
                       (-> %
                           (update-in [:lives] dec)
                           (update :particles
                                   (fn [particles]
                                     (vec (concat particles
                                                  (create-particles
                                                   :x new-x
                                                   :y new-y
                                                   :count 12
                                                   :color \"#FFFFFF\"))))))
                       %))))))))

Benefits of the improved version:

  • Single atomic state update
  • No race conditions
  • One Reagent re-render per hyperspace jump
  • Cleaner threading with -> macro
  • More functional approach with conditional logic

Erik’s Second Improvement: Pure Functions and State at the Edges

After reviewing the initial improvement, Erik Assum provided an even more profound insight about functional programming and state management:

“Looking at the commit, I think you can do even better. swap! takes an f state arg1 arg2 etc as params. So rather than passing anon fns to swap! You can pass named toplevel fns which take the state as first args, and whatever other args that fn might need, and your swap! becomes (swap! game-state do-whatever foo bar baz)”

“What you’ll see when you structure the code like that is that the state transforming fns become pure fns from data to data and are trivially unittestable (apart from your random-stuff which is inherently impure), and that the icky bits ie mutating the global state can be moved towards the edge of the program. You might even be able to do this with just one swap!”

This is a fundamental principle in functional programming: push side effects (like swap!) to the edges of your program, and keep the core logic pure.

Understanding Multi-Argument swap!

Most developers know swap! takes a function and applies it to the atom’s value:

(swap! atom update-fn)

But swap! can also pass additional arguments to your function:

;; swap! signature: (swap! atom f & args)
;; This calls: (f @atom arg1 arg2 arg3)
(swap! game-state my-function arg1 arg2 arg3)

This means your state transformation function receives the current state as its first argument, followed by any additional arguments you need!

Before: Anonymous Functions and Scattered swap! Calls

Our original code had anonymous functions mixed with swap!:

;; ❌ Old approach: Anonymous function with state reading inside
(defn init-level! [& {:keys [level]}]
  (let [num-asteroids (+ 3 level)]
    (swap! game-state assoc
           :asteroids (vec (for [_ (range num-asteroids)]
                             (create-asteroid-at-edge)))
           :bullets []
           :particles []
           ...)))

;; ❌ Old approach: Reading state, then swapping
(defn fire-bullet! []
  (let [{:keys [ship]} @game-state
        bullet-vx (* bullet-speed (Math/cos ...))]
    (swap! game-state update :bullets conj
           {:x (:x ship) :y (:y ship) ...})))

Problems with this approach:

  • Functions are impure - they read from and write to game-state
  • Hard to test - requires setting up the global atom
  • Difficult to reason about - state mutations scattered throughout
  • Not reusable - tightly coupled to the global game-state atom

After: Pure Functions with State as First Argument

Erik’s improvement transforms these into pure, testable functions:

;; ✅ New approach: Pure function taking state as first argument
(defn init-level!
  "Initializes a new level - returns new state"
  [{:keys [level ship] :as game-state}]
  (let [num-asteroids (+ 3 level)]
    (merge game-state
           {:asteroids (vec (for [_ (range num-asteroids)]
                             (create-asteroid-at-edge)))
            :bullets []
            :particles []
            :ufo-timer (+ 600 (rand-int 600))
            :ship (assoc ship :invulnerable 120)})))

;; Usage: Pass the function directly to swap!
(swap! game-state init-level!)

Look at that! No @game-state, no nested swap!, just a pure function from data to data.

More Examples: fire-bullet! and ufo-fire!

The pattern applies beautifully to all state transformations:

;; ✅ fire-bullet! - Pure function
(defn fire-bullet!
  "Fires a bullet from the ship - returns new state"
  [{:keys [ship] :as game-state}]
  (play-laser-sound)  ; Side effect happens here, not in state logic
  (let [{:keys [x y angle]} ship
        bullet-vx (* bullet-speed (Math/cos (- angle (/ Math/PI 2))))
        bullet-vy (* bullet-speed (Math/sin (- angle (/ Math/PI 2))))]
    (update game-state :bullets conj
            {:x x :y y
             :vx bullet-vx
             :vy bullet-vy
             :life bullet-lifetime})))

;; Usage: Clean and simple!
(swap! game-state fire-bullet!)

;; ✅ ufo-fire! - Pure function with additional arguments
(defn ufo-fire!
  "UFO fires bullet at ship - returns new state"
  [{:keys [ship] :as game-state} & {:keys [ufo]}]
  (let [dx (- (:x ship) (:x ufo))
        dy (- (:y ship) (:y ufo))
        angle (Math/atan2 dy dx)
        bullet-vx (* 5 (Math/cos angle))
        bullet-vy (* 5 (Math/sin angle))]
    (update game-state :bullets conj
            {:x (:x ufo) :y (:y ufo)
             :vx bullet-vx :vy bullet-vy
             :life bullet-lifetime
             :from-ufo true})))

;; Usage: Pass additional arguments after the function!
(swap! game-state ufo-fire! :ufo current-ufo)

The Pure Function: hyperspace

We can go even further and extract the hyperspace logic into a pure function:

;; ✅ Pure hyperspace transformation
(defn hyperspace
  "Pure function for hyperspace jump logic"
  [state new-x new-y died?]
  (-> state
      ;; Teleport ship
      (assoc-in [:ship :x] new-x)
      (assoc-in [:ship :y] new-y)
      (assoc-in [:ship :vx] 0)
      (assoc-in [:ship :vy] 0)
      (assoc :hyperspace-cooldown hyperspace-cooldown)
      ;; Conditionally handle death
      (#(if died?
          (-> %
              (update-in [:lives] dec)
              (update :particles
                      (fn [particles]
                        (vec (concat particles
                                     (create-particles
                                      :x new-x :y new-y
                                      :count 12 :color \"#FFFFFF\"))))))
          %))))

;; ✅ hyperspace! - Handles side effects and randomness at the edge
(defn hyperspace!
  "Hyperspace jump with risk - coordinates randomness and sound"
  [game-state]
  (when (<= (:hyperspace-cooldown game-state) 0)
    (play-hyperspace-sound)  ; Side effect
    (let [new-x (rand-int canvas-width)      ; Randomness
          new-y (rand-int canvas-height)     ; Randomness
          died? (< (rand) 0.1)]              ; Randomness
      (hyperspace game-state new-x new-y died?))))  ; Pure logic!

;; Usage:
(swap! game-state hyperspace!)

Notice how we separated:

  1. Randomness and side effects (hyperspace! - at the edge)
  2. Pure transformation logic (hyperspace - testable core)

Benefits of This Pattern

1. Trivially Testable

Pure functions are easy to test:

;; Test fire-bullet! without any atoms!
(deftest fire-bullet-test
  (let [initial-state {:ship {:x 400 :y 300 :angle 0}
                       :bullets []}
        new-state (fire-bullet! initial-state)]
    (is (= 1 (count (:bullets new-state))))
    (is (= 400 (-> new-state :bullets first :x)))))

;; Test hyperspace death logic
(deftest hyperspace-death-test
  (let [initial-state {:ship {:x 0 :y 0}
                       :lives 3
                       :particles []}
        new-state (hyperspace initial-state 400 300 true)]
    (is (= 2 (:lives new-state)))
    (is (= 400 (get-in new-state [:ship :x])))
    (is (< 0 (count (:particles new-state))))))

2. State Mutations at the Edges

All swap! calls are now at the edges of the program:

;; In the game loop (edge of the program)
(when (= key \" \")
  (swap! game-state fire-bullet!))

;; In the game loop (edge of the program)
(when empty-asteroids
  (swap! game-state init-level!))

;; In the game loop (edge of the program)
(when should-fire-ufo
  (swap! game-state ufo-fire! :ufo current-ufo))

The core game logic is now pure functions that transform data!

3. Easier to Reason About

;; ❌ Before: What does this do? Need to read implementation
(fire-bullet!)

;; ✅ After: Clear data transformation
(swap! game-state fire-bullet!)
;; I know: takes current state, returns new state with bullet added

4. Reusable and Composable

Pure functions can be composed:

;; Compose transformations!
(-> initial-state
    (fire-bullet!)
    (fire-bullet!)
    (fire-bullet!))

;; Test edge cases without atoms
(-> empty-state
    (init-level!)
    (fire-bullet!)
    (:bullets)
    (count))

The Pattern in Action

Here’s how it looks in the actual game loop:

;; In the game-canvas component
(.addEventListener js/window \"keydown\"
  (fn [e]
    (when (= (:game-status @game-state) :playing)
      (case (.-key e)
        \" \" (swap! game-state fire-bullet!)      ; Clean!
        \"x\" (swap! game-state hyperspace!)        ; Simple!
        \"X\" (swap! game-state hyperspace!)        ; Readable!
        nil))))

;; In update-game!
(when (and (= (mod (:frame-count @game-state) 60) 0)
           (< (rand) 0.3))
  (swap! game-state ufo-fire! :ufo u))           ; Clear intent!

;; When level complete
(when (empty? asteroids)
  (play-level-complete-sound)
  (swap! game-state update :level inc)
  (swap! game-state init-level!))                ; One swap per action!

Key Takeaways from Erik’s Feedback

  1. swap! takes multiple arguments: (swap! atom f arg1 arg2) calls (f @atom arg1 arg2)
  2. State as first argument: Functions receive current state, return new state
  3. Pure functions: No reading @game-state inside transformation functions
  4. Side effects at edges: swap! calls happen at program boundaries (event handlers, game loop)
  5. Trivially testable: Pure functions need no mocking or setup
  6. Named top-level functions**: Not anonymous functions buried in swap! calls

This pattern transforms imperative, stateful code into functional, testable data transformations. As Erik noted: “the state transforming fns become pure fns from data to data and are trivially unittestable.”

Thank you, Erik, for this profound insight! This improvement makes the codebase more maintainable, testable, and exemplifies functional programming best practices. 🙏

UFO Enemies

UFOs spawn periodically and actively hunt the player:

(defn create-ufo
  []
  (let [side (if (< (rand) 0.5) 0 canvas-width)
        y (+ 50 (rand-int (- canvas-height 100)))]
    {:x side
     :y y
     :vx (if (= side 0) ufo-speed (- ufo-speed))
     :vy 0
     :size ufo-size
     :shoot-timer 0}))

;; Pure function - takes state, returns new state
(defn ufo-fire!
  "UFO fires bullet at ship - returns new state"
  [{:keys [ship] :as game-state} & {:keys [ufo]}]
  (let [dx (- (:x ship) (:x ufo))
        dy (- (:y ship) (:y ufo))
        angle (Math/atan2 dy dx)
        bullet-vx (* 5 (Math/cos angle))
        bullet-vy (* 5 (Math/sin angle))]
    (update game-state :bullets conj
            {:x (:x ufo) :y (:y ufo)
             :vx bullet-vx :vy bullet-vy
             :life bullet-lifetime
             :from-ufo true})))

;; Usage in game loop:
;; (swap! game-state ufo-fire! :ufo current-ufo)

Game Loop Architecture

graph LR A[requestAnimationFrame] --> B{Game Playing?} B -->|Yes| C[Handle Input] C --> D[Update Physics] D --> E[Check Collisions] E --> F[Update Particles] F --> G[Spawn UFOs] G --> H[Render Canvas] H --> I[Next Frame] I --> A B -->|No| J[Pause/Wait]

The game loop runs at 60 FPS using requestAnimationFrame:

(letfn [(game-loop []
          ;; Handle continuous input (rotation, thrust)
          (when (= (:game-status @game-state) :playing)
            (when (contains? @keys-pressed \"ArrowLeft\")
              (swap! game-state update-in [:ship :angle] - rotation-speed))
            (when (contains? @keys-pressed \"ArrowRight\")
              (swap! game-state update-in [:ship :angle] + rotation-speed))
            (if (contains? @keys-pressed \"ArrowUp\")
              (swap! game-state assoc-in [:ship :thrusting] true)
              (swap! game-state assoc-in [:ship :thrusting] false)))

          ;; Update all game entities
          (update-game!)

          ;; Render to canvas
          (draw-game! :ctx ctx)

          ;; Schedule next frame
          (reset! animation-id (js/requestAnimationFrame game-loop)))]
  (game-loop))

Particle Effects System

Explosions create satisfying particle effects:

(defn create-particles
  [& {:keys [x y count color]}]
  (for [_ (range count)]
    {:x x
     :y y
     :vx (* (- (rand) 0.5) 5)  ; Random velocity
     :vy (* (- (rand) 0.5) 5)
     :life 30                   ; Frames to live
     :color color}))

;; Particles fade and decay over time
(doseq [particle particles]
  (set! (.-fillStyle ctx) (:color particle))
  (set! (.-globalAlpha ctx) (/ (:life particle) 30))
  (.fillRect ctx (:x particle) (:y particle) 2 2))

Keyword Arguments Pattern

All functions use keyword arguments for clarity and maintainability:

;; ❌ Hard to read
(draw-asteroid ctx 100 200 shape 1.5)

;; ✅ Self-documenting
(draw-asteroid :ctx ctx :x 100 :y 200 :shape shape :angle 1.5)

;; ❌ Position matters
(create-asteroid 300 400 :large)

;; ✅ Clear intent
(create-asteroid :x 300 :y 400 :size-type :large)

Canvas Rendering Techniques

Efficient rendering with canvas transformations:

(defn draw-asteroid
  [& {:keys [ctx x y shape angle]}]
  (.save ctx)                    ; Save canvas state
  (.translate ctx x y)           ; Move to asteroid position
  (.rotate ctx angle)            ; Rotate to current angle
  (set! (.-strokeStyle ctx) \"#FFFFFF\")
  (.beginPath ctx)
  (let [first-vertex (first shape)]
    (.moveTo ctx (:x first-vertex) (:y first-vertex)))
  (doseq [vertex (rest shape)]
    (.lineTo ctx (:x vertex) (:y vertex)))
  (.closePath ctx)
  (.stroke ctx)
  (.restore ctx))               ; Restore canvas state

Progressive Difficulty System

Each level increases the challenge:

(defn init-level!
  [& {:keys [level]}]
  (let [num-asteroids (+ 3 level)]  ; More asteroids each level!
    (swap! game-state assoc
           :asteroids (vec (for [_ (range num-asteroids)]
                             (create-asteroid-at-edge)))
           :bullets []
           :particles []
           :ufo-timer (+ 600 (rand-int 600))
           :ship (assoc (:ship @game-state)
                        :invulnerable 120))))  ; Invulnerability frames

;; Scoring system rewards skill
(def asteroid-points {:large 20 :medium 50 :small 100})
(def ufo-points 200)

Learning Points

This project teaches several advanced game development concepts:

1. Physics Simulation

  • Newton’s Laws: Objects in motion stay in motion
  • Friction: Gradual velocity decay (multiply by 0.99)
  • Thrust: Apply force in the direction ship faces
  • Max Velocity: Prevent unrealistic speeds
  • Wrap-around: Screen edge teleportation

2. Collision Detection

  • Circle-Circle: Distance-based detection
  • Optimization: Only check active objects
  • Batch Processing: Collect all collisions before applying
  • Response: Separate detection from response logic
  • Invulnerability: Temporary immunity after hits

3. Game Feel

  • Particle Effects: Visual feedback for actions
  • Sound Effects: Audio feedback for every action
  • Web Audio API: Procedural sound synthesis
  • Retro Tones: Frequency-based arcade sounds
  • Difficulty Curve: Progressive challenge

4. State Management

  • Single Source of Truth: One atom for all state
  • Immutable Updates: Pure functional transformations
  • Pure State Functions: Functions take state, return new state (Erik’s feedback)
  • Atomic Updates: Single swap! per operation (Erik’s feedback)
  • State at Edges: Mutations pushed to program boundaries
  • Predictable Updates: No side effects in core logic
  • Reactive Rendering: Automatic UI updates

Mobile Touch Controls Implementation

The Challenge

Making Asteroids playable on mobile required solving several problems:

  1. No keyboard: Touch events completely different from key presses
  2. Continuous input: Ship rotation and thrust need smooth, continuous control
  3. Multiple actions: Fire bullets while steering and thrusting
  4. Visual feedback: Players need to see what they’re touching

The Solution: Virtual Joystick

We implemented a virtual joystick that appears in the bottom-left corner when the game starts:

;; Touch state management
(def game-state
  (r/atom {;; ... existing state
           :touch-controls {:joystick {:active false
                                       :x 0
                                       :y 0
                                       :angle 0
                                       :distance 0}
                            :fire-button false
                            :hyperspace-button false}}))

;; Helper function to find touch by ID (avoiding array-seq issues)
(defn find-touch-by-id
  [touch-list touch-id]
  (when touch-list
    (loop [i 0]
      (when (< i (.-length touch-list))
        (let [touch (aget touch-list i)]
          (if (= (.-identifier touch) touch-id)
            touch
            (recur (inc i))))))))

Joystick Physics Calculations

The joystick calculates angle and distance from center:

(defn calculate-joystick-angle
  [& {:keys [center-x center-y touch-x touch-y]}]
  (let [dx (- touch-x center-x)
        dy (- touch-y center-y)]
    (Math/atan2 dy dx)))

(defn calculate-joystick-distance
  [& {:keys [center-x center-y touch-x touch-y max-distance]}]
  (let [dx (- touch-x center-x)
        dy (- touch-y center-y)
        dist (Math/sqrt (+ (* dx dx) (* dy dy)))]
    (min dist max-distance)))

(defn normalize-joystick-input
  [& {:keys [distance max-distance]}]
  (/ distance max-distance))

Touch Event Handling

The virtual joystick component handles touchstart, touchmove, and touchend:

(defn virtual-joystick []
  (let [joystick-ref (atom nil)
        touch-id (atom nil)
        max-distance 50]
    (r/create-class
     {:component-did-mount
      (fn []
        (when-let [joystick @joystick-ref]
          ;; touchstart: Record the touch and activate joystick
          (let [handle-touch-start
                (fn [e]
                  (.preventDefault e)
                  (let [touch (aget (.-touches e) 0)]
                    (reset! touch-id (.-identifier touch))
                    (swap! game-state assoc-in
                           [:touch-controls :joystick :active] true)))

                ;; touchmove: Calculate angle and distance
                handle-touch-move
                (fn [e]
                  (.preventDefault e)
                  (when @touch-id
                    (let [touches (.-touches e)
                          touch (find-touch-by-id touches @touch-id)]
                      (when touch
                        (let [center (get-joystick-center)
                              angle (calculate-joystick-angle ...)
                              distance (calculate-joystick-distance ...)]
                          (swap! game-state assoc-in
                                 [:touch-controls :joystick]
                                 {:active true :angle angle
                                  :distance distance ...}))))))]
            (.addEventListener joystick \"touchstart\" handle-touch-start)
            (.addEventListener joystick \"touchmove\" handle-touch-move))))

      :render
      (fn []
        [:div {:style {:position \"fixed\" :bottom \"40px\" :left \"40px\"
                       :width \"120px\" :height \"120px\"
                       :border-radius \"50%\"
                       :background \"rgba(255, 255, 255, 0.2)\"}}
         ;; Inner draggable circle shows touch position
         [:div {:style {:width \"60px\" :height \"60px\"
                       :transform (str \"translate(\" x \"px, \" y \"px)\")}}]])})))

Action Buttons

Separate buttons for firing and hyperspace:

(defn touch-action-button
  [& {:keys [label bottom right on-press on-release color]}]
  ;; Creates circular button with touch event handlers
  ;; FIRE button: Green, fires bullets
  ;; HYPER button: Orange, activates hyperspace
  ...)

Game Loop Integration

The game loop checks both keyboard and touch input:

(letfn [(game-loop []
          (when (= (:game-status @game-state) :playing)
            (let [joystick (get-in @game-state [:touch-controls :joystick])]

              ;; Keyboard rotation
              (when (contains? @keys-pressed \"ArrowLeft\")
                (swap! game-state update-in [:ship :angle] - rotation-speed))

              ;; Touch joystick rotation and thrust
              (when (:active joystick)
                (let [normalized-distance (normalize-joystick-input
                                           :distance (:distance joystick)
                                           :max-distance 50)
                      joy-angle (:angle joystick)]
                  ;; Rotate ship to match joystick angle
                  (swap! game-state assoc-in [:ship :angle]
                         (+ joy-angle (/ Math/PI 2)))
                  ;; Thrust when joystick pushed > 30%
                  (swap! game-state assoc-in [:ship :thrusting]
                         (> normalized-distance 0.3))))))
          ...)]
  (game-loop))

Mobile-Specific Challenges

  1. Touch ID Tracking: Each touch has a unique identifier to handle multi-touch
  2. Preventing Default: Stop browser scrolling and zooming during gameplay
  3. Visual Feedback: Button and joystick colors change when active
  4. Z-Index Management: Controls overlay the game canvas
  5. Scittle Compatibility: Avoiding array-seq which isn’t available in Scittle

Critical Performance Fixes

The Exponential Asteroid Explosion Bug

The original collision detection had a fatal flaw:

;; ❌ BROKEN: Multiple bullets can hit same asteroid in one frame
(doseq [bullet bullets
        asteroid asteroids]
  (when (check-collision bullet asteroid)
    (swap! game-state update :bullets remove-bullet)
    (swap! game-state update :asteroids remove-asteroid)
    (swap! game-state update :asteroids concat (split-asteroid asteroid))))

The Problem: If 3 bullets hit a large asteroid in the same frame:

  • Asteroid splits into 2 medium (first hit)
  • Those 2 medium split into 4 small (second hit)
  • Those 4 small would split further (third hit)
  • Result: Exponential growth from 1 asteroid to potentially 8+ asteroids
  • Within seconds: hundreds of asteroids and thousands of particles
  • Game freezes, becomes unplayable

The Fix: Collision Batching

We collect all collisions first, then apply them once:

;; ✅ FIXED: Batch collision detection and resolution
(let [hit-bullets (atom #{})
      hit-asteroids (atom #{})
      new-asteroids (atom [])
      score-added (atom 0)
      new-particles (atom [])]

  ;; Find all collisions (but don't apply yet)
  (doseq [bullet bullets
          :when (and (not (:from-ufo bullet))
                     (not (contains? @hit-bullets bullet)))
          asteroid asteroids
          :when (not (contains? @hit-asteroids asteroid))]
    (when (check-collision bullet asteroid)
      ;; Mark as hit (prevents duplicate processing)
      (swap! hit-bullets conj bullet)
      (swap! hit-asteroids conj asteroid)
      ;; Collect effects
      (swap! new-asteroids concat (split-asteroid asteroid))
      (swap! score-added + points)
      (swap! new-particles concat (create-particles ...))))

  ;; Apply all effects at once
  (when (seq @hit-bullets)
    (swap! game-state update :bullets remove-hit-bullets)
    (swap! game-state update :asteroids remove-hit-asteroids)
    (swap! game-state update :asteroids concat-new-asteroids)))

Performance Safeguards

We added safety limits to prevent performance degradation:

;; Safety limits
(def max-asteroids 50)    ;; Cap total asteroid count
(def max-particles 200)   ;; Cap total particle count

;; Reduce particle counts
(defn create-particles [& {:keys [count ...]}]
  ;; Asteroid hit: 10 → 5 particles
  ;; UFO explosion: 15 → 8 particles
  ;; Ship explosion: 20 → 12 particles
  ...)

;; Apply limits when adding new objects
(swap! game-state update :asteroids
       #(vec (take max-asteroids (concat current new-ones))))

(swap! game-state update :particles
       #(vec (take max-particles (concat current new-ones))))

Impact of Performance Fixes

Before fixes:

  • Rapid firing → 100+ asteroids in seconds
  • 1000+ particles causing visual chaos
  • Frame rate drops from 60 FPS to < 10 FPS
  • Game becomes unplayable, browser may freeze
  • Mobile devices especially affected

After fixes:

  • Maximum 50 asteroids (smooth on all devices)
  • Maximum 200 particles (clean visual effects)
  • Consistent 60 FPS on desktop and mobile
  • No collision bugs or exponential growth
  • Enjoyable gameplay even during intense action

Lessons Learned

  1. Test with real users: Kids found the bug immediately through enthusiastic rapid-fire testing
  2. Batch state updates: Collecting changes before applying prevents race conditions
  3. Use sets for tracking: Prevents duplicate processing of the same object
  4. Add safety limits: Upper bounds prevent worst-case scenarios
  5. Optimize particle counts: Fewer particles with better placement looks just as good
  6. Profile on mobile: Desktop performance doesn’t predict mobile behavior

The Stale Data Bug - Collision Detection with Old Positions

After fixing the exponential explosion bug, we discovered another critical issue: bullets weren’t destroying asteroids reliably! This was the exact same bug we encountered in our Galaga implementation.

The Problem: Using Captured State

The collision detection code was using bullets and asteroids captured at the START of the update-game! function, but checking collisions AFTER updating their positions:

;; ❌ BROKEN: Stale data causes missed collisions
(defn update-game! []
  (when (= (:game-status @game-state) :playing)
    (let [{:keys [bullets asteroids ufos]} @game-state]  ; ← Captured at START

      ;; Update bullet positions
      (swap! game-state update :bullets
             (fn [bs]
               (vec (for [b bs
                          :let [new-b (-> b
                                          (update :x #(wrap-position ...))
                                          (update :y #(wrap-position ...)))]
                          :when (> (:life new-b) 0)]
                      new-b))))

      ;; Update asteroid positions
      (swap! game-state update :asteroids
             (fn [as]
               (vec (for [a as]
                      (-> a
                          (update :x #(wrap-position ...))
                          (update :y #(wrap-position ...)))))))

      ;; Check collisions - BUG: Uses OLD positions from line 2!
      (doseq [bullet bullets              ; ← OLD positions before movement
              asteroid asteroids]         ; ← OLD positions before movement
        (when (check-collision bullet asteroid)
          ...))

Why This Fails:

  1. Frame starts: Bullet at x=100, Asteroid at x=105 (not colliding)
  2. Bullet moves to x=108 (NOW colliding with asteroid!)
  3. Asteroid moves to x=110
  4. Collision check uses OLD positions (100 vs 105) - NO collision detected!
  5. Result: Bullet passes right through asteroid

The Fix: Fresh State Captures

Capture the CURRENT state right before collision detection, AFTER all position updates:

;; ✅ FIXED: Use current state after position updates
(defn update-game! []
  (when (= (:game-status @game-state) :playing)
    (let [{:keys [bullets asteroids ufos]} @game-state]

      ;; Update bullet positions
      (swap! game-state update :bullets ...)

      ;; Update asteroid positions
      (swap! game-state update :asteroids ...)

      ;; Capture FRESH state after all updates
      (let [current-bullets (:bullets @game-state)     ; ← FRESH after updates
            current-asteroids (:asteroids @game-state) ; ← FRESH after updates
            current-ufos (:ufos @game-state)           ; ← FRESH after updates
            hit-bullets (atom #{})
            hit-asteroids (atom #{})]

        ;; Now collision detection uses CURRENT positions
        (doseq [bullet current-bullets              ; ← Current frame positions
                :when (not (contains? @hit-bullets bullet))
                asteroid current-asteroids]         ; ← Current frame positions
          (when (check-collision bullet asteroid)
            ...))

        ;; UFO collisions also use current state
        (doseq [bullet current-bullets              ; ← Reuse fresh capture
                :when (not (contains? @hit-bullets bullet))
                ufo current-ufos]                   ; ← Current frame positions
          (when (check-collision bullet ufo)
            ...))

The Key Insight

The let binding at the function start creates a snapshot of the game state. Any subsequent swap! calls modify the atom, but the let variables still point to the old data. For collision detection to work correctly, we must capture fresh state AFTER all position updates complete.

Scope Considerations

Initially, we made a mistake: we defined current-bullets and current-ufos in the first collision detection block (asteroids) and tried to use them in the second block (UFOs). This failed because each let block has its own scope!

The solution: Each collision detection block captures its own fresh data:

;; Bullet-Asteroid collisions
(let [current-bullets (:bullets @game-state)
      current-asteroids (:asteroids @game-state)
      current-ufos (:ufos @game-state)  ; ← Also captured for UFO collisions
      ...]
  ...)

;; Bullet-UFO collisions (separate scope!)
(let [current-bullets (:bullets @game-state)  ; ← Fresh capture again
      current-ufos (:ufos @game-state)        ; ← Fresh capture again
      ...]
  ...)

Learning from Galaga

This was the identical bug we fixed in our Galaga game! The pattern is common in game loops:

  1. Capture state at function start for reference
  2. Update multiple collections via swap!
  3. Check interactions between updated objects
  4. Mistake : Using initial captures instead of current state

The fix is always the same: capture fresh state right before collision detection.

Impact After Fix

Before:

  • Bullets often passed through asteroids
  • Fast-moving bullets especially problematic
  • UFOs seemed invulnerable
  • Frustrating gameplay experience

After:

  • Precise collision detection
  • Bullets reliably destroy asteroids
  • UFOs react properly to hits
  • Satisfying, responsive gameplay

Additional Lessons

  1. Timing matters: State captured at start vs. end of update cycle matters!
  2. Test collision detection: Easy to miss stale data bugs during casual play
  3. Reuse patterns: Same bug/fix across multiple games suggests a general principle
  4. Scope awareness: Remember that let blocks don’t share bindings
  5. Think in terms of frames: What state exists at each point in the game loop?

Retro Sound Effects with Web Audio API

No arcade game is complete without sound! We added authentic retro sound effects using the Web Audio API, following the same pattern used in our Galaga implementation.

The Audio System Architecture

The sound system uses procedurally generated tones to create that classic arcade feel:

;; Initialize Web Audio API context
(def audio-context
  "Web Audio API context for sound generation"
  (when (exists? js/AudioContext)
    (js/AudioContext.)))

;; Generic tone generator
(defn play-tone
  "Plays a tone at the specified frequency for the given duration"
  [& {:keys [frequency duration volume]
      :or {frequency 440 duration 0.2 volume 0.3}}]
  (when audio-context
    (try
      (let [oscillator (.createOscillator audio-context)
            gain-node (.createGain audio-context)]
        (.connect oscillator gain-node)
        (.connect gain-node (.-destination audio-context))
        (set! (.-value (.-frequency oscillator)) frequency)
        (set! (.-value (.-gain gain-node)) volume)
        (.start oscillator)
        (.stop oscillator (+ (.-currentTime audio-context) duration)))
      (catch js/Error e
        (js/console.error "Audio error:" e)))))

Sound Effect Implementations

Each game event has its own characteristic sound:

;; 1. Laser Sound - High frequency burst
(defn play-laser-sound []
  (play-tone :frequency 800 :duration 0.1 :volume 0.2))

;; 2. Explosion Sound - Low rumble
(defn play-explosion-sound []
  (play-tone :frequency 100 :duration 0.2 :volume 0.3))

;; 3. Thrust Sound - Continuous engine hum
(defn play-thrust-sound []
  (play-tone :frequency 150 :duration 0.08 :volume 0.15))

;; 4. Hyperspace - Descending frequency sweep
(defn play-hyperspace-sound []
  (doseq [[idx freq] (map-indexed vector [880 660 440 220])]
    (js/setTimeout
     #(play-tone :frequency freq :duration 0.1 :volume 0.25)
     (* idx 50))))

;; 5. Hit Sound - Dramatic impact
(defn play-hit-sound []
  (play-tone :frequency 150 :duration 0.3 :volume 0.4))

;; 6. Victory Sound - Ascending fanfare
(defn play-level-complete-sound []
  (doseq [[idx freq] (map-indexed vector [523 659 784 1047])]
    (js/setTimeout
     #(play-tone :frequency freq :duration 0.2 :volume 0.25)
     (* idx 100))))

Integrating Sounds with Game Events

Sounds are triggered at key moments for maximum impact:

;; Fire weapon
(defn fire-bullet! []
  (play-laser-sound)  ; Instant audio feedback
  (let [{:keys [x y angle]} (:ship @game-state)]
    (swap! game-state update :bullets conj ...)))

;; Asteroid destruction
(when (seq @hit-bullets)
  (play-explosion-sound)
  (swap! game-state update :bullets ...))

;; Ship collision
(when (check-collision ship asteroid)
  (play-hit-sound)
  (swap! game-state update :lives dec))

;; Level complete
(when (empty? asteroids)
  (play-level-complete-sound)
  (swap! game-state update :level inc))

Throttling Continuous Sounds

The thrust sound needs special handling to avoid audio spam:

;; Play thrust sound (throttled to every 8 frames)
(when (and (:thrusting ship)
           (= (mod (:frame-count @game-state) 8) 0))
  (play-thrust-sound))

This creates a continuous engine sound effect while preventing hundreds of simultaneous audio oscillators!

Sound Design Philosophy

The sound effects follow classic arcade game principles:

  1. Laser (800Hz): High frequency for energy weapons
  2. Explosion (100Hz): Low rumble for impacts
  3. Thrust (150Hz): Mid-low hum for engines
  4. Hyperspace (descending): Frequency sweep for warp effect
  5. Hit (150Hz): Sustained tone for damage feedback
  6. Victory (ascending): Rising pitch for achievement

Browser Compatibility

The audio system gracefully handles browsers without Web Audio API support:

(when audio-context  ; Only play if API available
  (try
    ;; Generate sound
    (catch js/Error e
      (js/console.error "Audio error:" e))))

Learning from Galaga

We followed the same sound implementation pattern from our Galaga game:

  • Single audio-context initialization
  • Generic play-tone function for all sounds
  • Specific sound functions with descriptive names
  • Integration at game event trigger points
  • Error handling for unsupported browsers

This consistent approach makes it easy to add sound to any Scittle-based game!

Try the Game!

The complete Asteroids game is embedded below. Works on both desktop and mobile!

Desktop Controls:

  • Arrow keys to rotate and thrust
  • Spacebar to fire
  • X for hyperspace

Mobile Controls:

  • Virtual joystick (bottom-left) to rotate and thrust
  • FIRE button (green, right side) to shoot
  • HYPER button (orange, right side) for hyperspace

Extending the Game

Here are ideas to enhance your Asteroids clone:

1. Power-Ups

  • Shield power-up (temporary invulnerability)
  • Rapid fire (increased fire rate)
  • Smart bombs (destroy all on-screen asteroids)
  • Extra lives at milestone scores

2. Visual Enhancements

  • Starfield background with parallax
  • Glowing particle trails
  • Screen shake on collisions
  • Explosion animations
  • Retro CRT shader effects

3. Enhanced Audio

The game now has retro sound effects! Here are ideas to enhance the audio further:

  • Background music loop: Add ambient space music
  • UFO siren: Classic arcade UFO warning sound
  • Power-up sounds: When collecting extra lives or shields
  • Menu sounds: UI interaction feedback
  • Dynamic volume: Lower music during intense action
  • Stereo panning: Position sounds left/right based on screen location
  • Sound variations: Randomize pitch slightly for variety
  • Mute toggle: Let players disable sound

Example of stereo panning:

(defn play-positioned-sound [x]
  (when audio-context
    (let [oscillator (.createOscillator audio-context)
          panner (.createStereoPanner audio-context)
          gain-node (.createGain audio-context)
          ;; Pan from -1 (left) to 1 (right) based on x position
          pan-value (- (* 2 (/ x canvas-width)) 1)]
      (set! (.-value (.-pan panner)) pan-value)
      (.connect oscillator panner)
      (.connect panner gain-node)
      (.connect gain-node (.-destination audio-context))
      ...)))

4. Game Modes

  • Survival Mode: Endless asteroids, no levels
  • Time Trial: Clear asteroids before time runs out
  • No UFO Mode: Just asteroids and physics
  • Pacifist: Dodge asteroids without firing
  • Bullet Hell: UFOs spawn more frequently

5. Modern Features

  • Local storage for persistent high scores
  • Leaderboard integration
  • Replay system
  • Achievement badges
  • Daily challenges

6. Physics Experiments

  • Gravity wells (black holes)
  • Variable friction zones
  • Bouncing asteroids
  • Chain reaction explosions
  • Magnetic fields affecting bullets

Performance Optimizations

For smooth 60 FPS gameplay:

;; Spatial partitioning for collision detection
(defn nearby-objects [x y objects radius]
  (filter #(< (distance :x1 x :y1 y :x2 (:x %) :y2 (:y %)) radius)
          objects))

;; Object pooling for bullets/particles
(def bullet-pool (atom []))

(defn get-bullet []
  (if-let [bullet (first @bullet-pool)]
    (do (swap! bullet-pool rest) bullet)
    {:x 0 :y 0 :vx 0 :vy 0 :life 0}))

;; Batch canvas operations
(defn draw-all-asteroids [ctx asteroids]
  (.save ctx)
  (set! (.-strokeStyle ctx) \"#FFFFFF\")
  (set! (.-lineWidth ctx) 2)
  (doseq [asteroid asteroids]
    (draw-asteroid-shape ctx asteroid))
  (.restore ctx))

Key Takeaways

Building Asteroids with Scittle demonstrates:

  1. Zero-Build Game Development - Iterate rapidly without tooling
  2. Physics Simulation - Realistic movement with simple math
  3. Collision Detection - Efficient spatial queries with batch processing
  4. Canvas Graphics - Vector-based retro rendering
  5. Game State Management - Single atom, pure functions, atomic updates
  6. Web Audio API - Procedural sound synthesis for retro effects
  7. Keyword Arguments - Self-documenting, maintainable code
  8. Browser APIs - Canvas, requestAnimationFrame, keyboard, touch, audio
  9. Functional Patterns - Immutable state, pure logic, pushing state to edges
  10. Code Review Integration - Erik’s feedback transformed our architecture: single atomic swaps AND pure state transformation functions

Technical Highlights

What makes this implementation special:

Clean Separation of Concerns

;; Physics (pure functions)
(defn update-physics [entity]
  (-> entity
      (update :x + (:vx entity))
      (update :y + (:vy entity))
      (update :vx * friction)))

;; Collision (pure functions)
(defn check-collision [obj1 obj2]
  (< (distance ...) (+ radius1 radius2)))

;; Rendering (side effects isolated)
(defn draw! [ctx state]
  (clear-canvas ctx)
  (draw-ship ctx (:ship state))
  (draw-asteroids ctx (:asteroids state)))

;; Sound (side effects isolated)
(defn play-laser-sound []
  (play-tone :frequency 800 :duration 0.1 :volume 0.2))

;; State updates (atoms)
(swap! game-state update-in [:ship] update-physics)

Single Atomic State Updates

Following Erik’s code review feedback, all state modifications use single swap! calls:

;; ❌ Multiple swaps - race conditions!
(swap! game-state assoc-in [:ship :x] new-x)
(swap! game-state assoc-in [:ship :y] new-y)
(swap! game-state assoc-in [:ship :vx] 0)

;; ✅ Single atomic update
(swap! game-state
       (fn [state]
         (-> state
             (assoc-in [:ship :x] new-x)
             (assoc-in [:ship :y] new-y)
             (assoc-in [:ship :vx] 0))))

Keyword Arguments Everywhere

Makes complex function calls readable:

;; Compare these calls:

;; Traditional
(create-asteroid 300 400 :large)
(draw-ship ctx 400 300 1.57 true 0)
(check-collision ship asteroid 10 40)

;; With keyword arguments
(create-asteroid :x 300 :y 400 :size-type :large)
(draw-ship :ctx ctx :x 400 :y 300 :angle 1.57 :thrusting true :invulnerable 0)
(check-collision :obj1 ship :obj2 asteroid :radius1 10 :radius2 40)

Conclusion

This Asteroids implementation demonstrates that you don’t need complex build tools, game engines, or frameworks to create engaging games that work across all devices. With Scittle, ClojureScript, and the Canvas API, you can build classic arcade games that run anywhere, load instantly, and are easy to modify.

The Development Journey

What started as a desktop game evolved through real-world usage:

  1. Initial Build: Classic desktop keyboard controls
  2. Kid Testing: “Dad, can we play on our phones?”
  3. Mobile Support: Virtual joystick and touch buttons added
  4. Performance Crisis: Kids discovered the exponential asteroid bug
  5. Optimization: Collision batching and safety limits implemented
  6. Polish: Smooth 60 FPS on both desktop and mobile

This iterative process shows the value of real user testing - especially with enthusiastic kids who push games to their limits!

Technical Achievements

The final implementation showcases:

  • Cross-Platform Input: Seamless keyboard and touch control
  • Robust Collision System: Batched updates prevent race conditions
  • Performance Optimization: Safety limits ensure smooth gameplay
  • Retro Sound Effects: Web Audio API for authentic arcade audio
  • Functional Programming: Pure functions with immutable state
  • Pure State Transformations: Functions take state as first arg, return new state
  • Atomic State Updates: Single swap! calls following Erik’s guidance
  • State at the Edges: Mutations pushed to program boundaries, core logic pure
  • Reactive UI: Reagent atoms for predictable updates
  • Zero Build Tools: Instant development feedback with Scittle
  • Keyword Arguments: Self-documenting, maintainable code

The combination of functional programming, reactive state management, physics simulation, and canvas graphics creates a solid foundation for game development. Whether you’re learning ClojureScript, teaching game programming, or just having fun recreating classics, this approach offers immediate feedback and pure development joy.

Now get out there and see how high you can score! Watch out for those UFOs - they’re smarter than they look! And remember, hyperspace is risky but sometimes necessary! 🚀📱

Pro Tips for High Scores

  • Use hyperspace as a last resort (remember that 10% death chance!)
  • Small asteroids are worth the most points (100 vs 20 for large)
  • UFOs give 200 points - prioritize them when they appear
  • Master the thrust-and-drift technique for precise control
  • Keep moving! Sitting still makes you an easy target
  • Break up large asteroids early before they become a swarm
  • Use screen wrap-around to your advantage for quick escapes
  • Listen to the sounds! Audio cues tell you what’s happening on screen

Want to see more browser-native ClojureScript projects? Check out my other articles on ClojureCivitas where we explore the boundaries of what’s possible without build tools!