|
816 | 816 | ;; 5. **Optimize particle counts**: Fewer particles with better placement looks just as good |
817 | 817 | ;; 6. **Profile on mobile**: Desktop performance doesn't predict mobile behavior |
818 | 818 |
|
| 819 | +;; ### The Stale Data Bug - Collision Detection with Old Positions |
| 820 | + |
| 821 | +;; 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. |
| 822 | + |
| 823 | +;; #### The Problem: Using Captured State |
| 824 | + |
| 825 | +;; 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: |
| 826 | + |
| 827 | +;; ```clojure |
| 828 | +;; ;; ❌ BROKEN: Stale data causes missed collisions |
| 829 | +;; (defn update-game! [] |
| 830 | +;; (when (= (:game-status @game-state) :playing) |
| 831 | +;; (let [{:keys [bullets asteroids ufos]} @game-state] ; ← Captured at START |
| 832 | +;; |
| 833 | +;; ;; Update bullet positions |
| 834 | +;; (swap! game-state update :bullets |
| 835 | +;; (fn [bs] |
| 836 | +;; (vec (for [b bs |
| 837 | +;; :let [new-b (-> b |
| 838 | +;; (update :x #(wrap-position ...)) |
| 839 | +;; (update :y #(wrap-position ...)))] |
| 840 | +;; :when (> (:life new-b) 0)] |
| 841 | +;; new-b)))) |
| 842 | +;; |
| 843 | +;; ;; Update asteroid positions |
| 844 | +;; (swap! game-state update :asteroids |
| 845 | +;; (fn [as] |
| 846 | +;; (vec (for [a as] |
| 847 | +;; (-> a |
| 848 | +;; (update :x #(wrap-position ...)) |
| 849 | +;; (update :y #(wrap-position ...))))))) |
| 850 | +;; |
| 851 | +;; ;; Check collisions - BUG: Uses OLD positions from line 2! |
| 852 | +;; (doseq [bullet bullets ; ← OLD positions before movement |
| 853 | +;; asteroid asteroids] ; ← OLD positions before movement |
| 854 | +;; (when (check-collision bullet asteroid) |
| 855 | +;; ...)) |
| 856 | +;; ``` |
| 857 | + |
| 858 | +;; **Why This Fails:** |
| 859 | +;; |
| 860 | +;; 1. Frame starts: Bullet at x=100, Asteroid at x=105 (not colliding) |
| 861 | +;; 2. Bullet moves to x=108 (NOW colliding with asteroid!) |
| 862 | +;; 3. Asteroid moves to x=110 |
| 863 | +;; 4. Collision check uses OLD positions (100 vs 105) - NO collision detected! |
| 864 | +;; 5. Result: Bullet passes right through asteroid |
| 865 | + |
| 866 | +;; #### The Fix: Fresh State Captures |
| 867 | + |
| 868 | +;; Capture the CURRENT state right before collision detection, AFTER all position updates: |
| 869 | + |
| 870 | +;; ```clojure |
| 871 | +;; ;; ✅ FIXED: Use current state after position updates |
| 872 | +;; (defn update-game! [] |
| 873 | +;; (when (= (:game-status @game-state) :playing) |
| 874 | +;; (let [{:keys [bullets asteroids ufos]} @game-state] |
| 875 | +;; |
| 876 | +;; ;; Update bullet positions |
| 877 | +;; (swap! game-state update :bullets ...) |
| 878 | +;; |
| 879 | +;; ;; Update asteroid positions |
| 880 | +;; (swap! game-state update :asteroids ...) |
| 881 | +;; |
| 882 | +;; ;; Capture FRESH state after all updates |
| 883 | +;; (let [current-bullets (:bullets @game-state) ; ← FRESH after updates |
| 884 | +;; current-asteroids (:asteroids @game-state) ; ← FRESH after updates |
| 885 | +;; current-ufos (:ufos @game-state) ; ← FRESH after updates |
| 886 | +;; hit-bullets (atom #{}) |
| 887 | +;; hit-asteroids (atom #{})] |
| 888 | +;; |
| 889 | +;; ;; Now collision detection uses CURRENT positions |
| 890 | +;; (doseq [bullet current-bullets ; ← Current frame positions |
| 891 | +;; :when (not (contains? @hit-bullets bullet)) |
| 892 | +;; asteroid current-asteroids] ; ← Current frame positions |
| 893 | +;; (when (check-collision bullet asteroid) |
| 894 | +;; ...)) |
| 895 | +;; |
| 896 | +;; ;; UFO collisions also use current state |
| 897 | +;; (doseq [bullet current-bullets ; ← Reuse fresh capture |
| 898 | +;; :when (not (contains? @hit-bullets bullet)) |
| 899 | +;; ufo current-ufos] ; ← Current frame positions |
| 900 | +;; (when (check-collision bullet ufo) |
| 901 | +;; ...)) |
| 902 | +;; ``` |
| 903 | + |
| 904 | +;; #### The Key Insight |
| 905 | + |
| 906 | +;; 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. |
| 907 | + |
| 908 | +;; #### Scope Considerations |
| 909 | + |
| 910 | +;; 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! |
| 911 | + |
| 912 | +;; **The solution:** Each collision detection block captures its own fresh data: |
| 913 | + |
| 914 | +;; ```clojure |
| 915 | +;; ;; Bullet-Asteroid collisions |
| 916 | +;; (let [current-bullets (:bullets @game-state) |
| 917 | +;; current-asteroids (:asteroids @game-state) |
| 918 | +;; current-ufos (:ufos @game-state) ; ← Also captured for UFO collisions |
| 919 | +;; ...] |
| 920 | +;; ...) |
| 921 | +;; |
| 922 | +;; ;; Bullet-UFO collisions (separate scope!) |
| 923 | +;; (let [current-bullets (:bullets @game-state) ; ← Fresh capture again |
| 924 | +;; current-ufos (:ufos @game-state) ; ← Fresh capture again |
| 925 | +;; ...] |
| 926 | +;; ...) |
| 927 | +;; ``` |
| 928 | + |
| 929 | +;; #### Learning from Galaga |
| 930 | + |
| 931 | +;; This was the identical bug we fixed in our Galaga game! The pattern is common in game loops: |
| 932 | +;; |
| 933 | +;; 1. Capture state at function start for reference |
| 934 | +;; 2. Update multiple collections via `swap!` |
| 935 | +;; 3. Check interactions between updated objects |
| 936 | +;; 4. **Mistake**: Using initial captures instead of current state |
| 937 | +;; |
| 938 | +;; The fix is always the same: capture fresh state right before collision detection. |
| 939 | + |
| 940 | +;; #### Impact After Fix |
| 941 | + |
| 942 | +;; **Before:** |
| 943 | +;; - Bullets often passed through asteroids |
| 944 | +;; - Fast-moving bullets especially problematic |
| 945 | +;; - UFOs seemed invulnerable |
| 946 | +;; - Frustrating gameplay experience |
| 947 | + |
| 948 | +;; **After:** |
| 949 | +;; - Precise collision detection |
| 950 | +;; - Bullets reliably destroy asteroids |
| 951 | +;; - UFOs react properly to hits |
| 952 | +;; - Satisfying, responsive gameplay |
| 953 | + |
| 954 | +;; #### Additional Lessons |
| 955 | + |
| 956 | +;; 7. **Timing matters**: State captured at start vs. end of update cycle matters! |
| 957 | +;; 8. **Test collision detection**: Easy to miss stale data bugs during casual play |
| 958 | +;; 9. **Reuse patterns**: Same bug/fix across multiple games suggests a general principle |
| 959 | +;; 10. **Scope awareness**: Remember that `let` blocks don't share bindings |
| 960 | +;; 11. **Think in terms of frames**: What state exists at each point in the game loop? |
| 961 | + |
819 | 962 | ;; ## Retro Sound Effects with Web Audio API |
820 | 963 |
|
821 | 964 | ;; 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. |
|
0 commit comments