rdefeo 1 год назад
Сommit
1ffb471a43

+ 41 - 0
.github/workflows/build.yml

@@ -0,0 +1,41 @@
+name: "FAP: Build for multiple SDK sources"
+# This will build your app for dev and release channels on GitHub. 
+# It will also build your app every day to make sure it's up to date with the latest SDK changes.
+# See https://github.com/marketplace/actions/build-flipper-application-package-fap for more information
+
+on:
+  push:
+    ## put your main branch name under "branches"
+    #branches: 
+    #  - master 
+  pull_request:
+  schedule: 
+    # do a build every day
+    - cron: "1 1 * * *"
+
+jobs:
+  ufbt-build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        include:
+          - name: dev channel
+            sdk-channel: dev
+          - name: release channel
+            sdk-channel: release
+          # You can add unofficial channels here. See ufbt action docs for more info.
+    name: 'ufbt: Build for ${{ matrix.name }}'
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Build with ufbt
+        uses: flipperdevices/flipperzero-ufbt-action@v0.1
+        id: build-app
+        with:
+          sdk-channel: ${{ matrix.sdk-channel }}
+      - name: Upload app artifacts
+        uses: actions/upload-artifact@v3
+        with:
+          # See ufbt action docs for other output variables
+          name: ${{ github.event.repository.name }}-${{ steps.build-app.outputs.suffix }}
+          path: ${{ steps.build-app.outputs.fap-artifacts }}

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+dist/*
+.vscode
+.clang-format
+.clangd
+.editorconfig
+.env
+.ufbt
+temp/*

+ 85 - 0
README.md

@@ -0,0 +1,85 @@
+# Pinball0 (Pinball Zero)
+Play pinball on your Flipperzero!
+
+This is a BETA release - like, I'm surprised it works! 
+
+> The default tables and example tables may / will change
+
+## Screenshots
+
+![menu](screenshots/screenshot_menu.png)
+![basic](screenshots/screenshot_basic.png)
+![el ocho](screenshots/screenshot_el_ocho.png)
+![chamber](screenshots/screenshot_chamber.png)
+
+## Controls
+* **Ok** to release the ball
+* **Left** and **Right** to activate the left and right flippers
+* **Back** to return to the main menu or exit
+* **Up** to "bump" the table if the ball gets stuck
+
+## Tables
+Pinball0 ships with several default tables. These tables are automatically deployed into the assets folder. Tables are simple JSON which means you can define your own! Your tables should be stored in the data folder. **The default tables may change over time.**
+
+### File Format
+Table units are specified at a 10x scale. This means our table is **630 x 1270** in size (as the F0 display is 64 pixels x 128 pixels). Our origin is in the top-left at 0, 0.
+
+The JSON can include comments - because why not!
+
+> **DISCLAIMER:** The file format may change from release to release. Sorry. There is some basic error checking when reading / parsing the table files. If the error is serious enough, you will see an error message in the app. Otherwise, check the console logs. For those familiar with `ufbt`, simply run `ufbt cli` and issue the `log` command. Then launch Pinball0. All informational and higher logs will be displayed.
+
+#### balls : list of objects
+Every table needs at least 1 ball, otherwise it will fail to load.
+
+* `"position": [ X, Y ]`
+* `"velocity": [ VX, VY ]` : optional, defaults to `[ 0, 0 ]`
+* `"radius" : N` : optional, defaults to `20`
+
+#### flippers : list of objects
+* `"position": [ X, Y ]` : location of the pivot point
+* `"side": S` : valid values are `"LEFT"` or `"RIGHT"`
+* `"size": N` : optional, defaults to `130`
+
+You can have more than 2 flippers! Try it!
+
+#### bumpers : list of objects
+* `"position": [ X, Y ]`
+* `"radius": [ N ]` : optional, defaults to `40`
+
+#### rails : list of objects
+* `"start": [ X, Y ]`
+* `"end": [ X, Y ]`
+* `"double_sided": bool` : optional, defaults to `false`
+
+The "surface" of a rail is "on the left" as we move from the `"start"` to the `"end"`. This means that when thinking about your table, the outer perimiter should be defined in a counter-clockwise order. Arriving at a rail from it's revrese side will result in a pass-through - think of it as a one-way mirror.
+
+#### arcs : list of objects
+* `"position": [ X, Y ]`
+* `"radius" : N`
+* `"start_angle": N` : in degrees from 0 to 360
+* `"end_angle": N` : in degreens from 0 to 360
+* `"surface": S` : valid values are `"INSIDE"` or `"OUTSIDE"`
+
+Start and End angles should be specified in **counter-clockwise** order. The **surface** defines which side will bounce/reflect.
+
+#### rollovers : list of objects
+* `"position": [ X, Y ]`
+* `"symbol": C` : where C is a string representing a single character, like `"Z"`
+
+When the ball passes over/through a rollover object, the symbol will appear. Only the first character of the string is used.
+
+#### portals : list of objects
+* `"a_start": [ X, Y]`
+* `"a_end": [ X, Y]`
+* `"b_start": [ X, Y]`
+* `"b_end": [ X, Y]`
+
+Defines two portals, **a** and **b**. They are bi-drectional. Like rails, their "surface" - or in this case, their "entry surface" - is "on the left" from their respective start to end direction. You can't "enter" a portal from it's reverse side, you will pass through.
+
+#### lives : int
+* `"lives": N` : optional, defaults to 1
+
+The number of starting lives/balls.
+
+#### lives_position : list of int
+* `"lives_position": [ X, Y ]` : where to draw the number of remaining lives

+ 19 - 0
application.fam

@@ -0,0 +1,19 @@
+# For details & more options, see documentation/AppManifests.md in firmware repo
+
+App(
+    appid="pinball0",
+    name="Pinball0",
+    apptype=FlipperAppType.EXTERNAL,
+    entry_point="pinball0_app",
+    stack_size=2 * 1024,
+    fap_category="Games",
+    requires=["gui"],
+    # Optional values
+    fap_version="0.1",
+    fap_icon="pinball0.png",  # 10x10 1-bit PNG
+    fap_description="Pinball game",
+    fap_author="Roberto De Feo",
+    fap_weburl="https://github.com/rdefeo/pinball0",
+    fap_icon_assets="images",  # Image assets to compile for this application
+    fap_file_assets="assets",
+)

+ 138 - 0
assets/tables/01_Basic.json

@@ -0,0 +1,138 @@
+{
+    "name": "Basic",
+    "balls": [
+        {
+            "position": [ 600, 1110 ],
+            "velocity": [ 0, -16.0 ]
+        }
+    ],
+    // "released": true,
+    // "plunger": {
+    //     "position": [ 600, 1200 ],
+    //     "size": 100
+    // },
+    "flippers": [
+        {
+            "position": [ 130, 1200 ],
+            "side": "LEFT",
+            "size": 130
+        },
+        {
+            "position": [ 490, 1200 ],
+            "side": "RIGHT",
+            "size": 130
+        }
+    ],
+    "bumpers": [
+        {
+            "position": [ 180, 280 ],
+            "radius": 60
+        },
+        {
+            "position": [ 470, 280 ],
+            "radius": 60
+        },
+        {
+            "position": [ 320, 430 ],
+            "radius": 50
+        },
+        {
+            "position": [ 200, 600 ],
+            "radius": 25
+        },
+        {
+            "position": [ 440, 900 ],
+            "radius": 20
+        },
+        {
+            "position": [ 220, 920 ],
+            "radius": 30
+        }
+    ],
+    "arcs": [
+        {
+            // top dome
+            "position": [ 320, 320 ],
+            "radius": 310,
+            "start_angle": 0,
+            "end_angle": 180,
+            "surface": "INSIDE"
+        }
+    ],
+    "rails": [
+        // left wall
+        {
+            "start": [ 0, 320 ],
+            "end": [ 0, 1150 ]
+        },
+        // right wall
+        {
+            "start": [ 630, 1150 ],
+            "end": [ 630, 320 ]
+        },
+        // left bottom rail
+        {
+            "start": [ 70, 900 ],
+            "end": [ 70, 1150 ],
+            "double_sided": true
+        },
+        {
+            "start": [ 70, 1150 ],
+            "end": [ 130, 1180 ],
+            "double_sided": true
+        },
+        // right bottom rail
+        {
+            "start": [ 560, 1150 ],
+            "end": [ 560, 900 ],
+            "double_sided": true
+        },
+        {
+            "start": [ 490, 1180 ],
+            "end": [ 560, 1150 ],
+            "double_sided": true
+        },
+        {
+            // left shooter stopper
+            "start": [ 40, 180 ],
+            "end": [ 120, 240 ],
+            "bounce": 0.65
+        },
+        {
+            // right shooter pass-through
+            "start": [ 510, 240 ],
+            "end": [ 600, 180 ],
+            "bounce": 0.65
+        },
+        {
+            // top middle rail
+            "start": [ 320, 140 ],
+            "end": [ 320, 220 ],
+            "double_sided": true
+        },
+        {
+            // right side triangle thing 1/3
+            "start": [ 560, 390 ],
+            "end": [ 560, 700 ]
+        },
+        {
+            // right side triangle thing 2/3
+            "start": [ 560, 700 ],
+            "end": [ 490, 640 ]
+        },
+        {
+            // right side triangle thing 3/3
+            "start": [ 490, 640 ],
+            "end": [ 560, 390 ]
+        },
+        {
+            // left side thing 1/2
+            "start": [ 0, 430 ],
+            "end": [ 70, 570 ]
+        },
+        {
+            "start": [ 70, 570 ],
+            "end": [ 0, 750 ]
+        }
+    ]
+}

+ 128 - 0
assets/tables/02_Classic.json

@@ -0,0 +1,128 @@
+{
+    "name": "Classic",
+    "balls": [
+        {
+            "position": [ 600, 1110 ],
+            "velocity": [ 0, -12.0 ]
+        }
+    ],
+    "flippers": [
+        {
+            "position": [ 130, 1200 ],
+            "side": "LEFT",
+            "size": 130
+        },
+        {
+            "position": [ 490, 1200 ],
+            "side": "RIGHT",
+            "size": 130
+        }
+    ],
+    "bumpers": [
+        {
+            "position": [ 200, 260 ],
+            "radius": 60
+        },
+        {
+            "position": [ 450, 200 ],
+            "radius": 60
+        },
+        {
+            "position": [ 280, 550 ],
+            "radius": 40
+        },
+        {
+            "position": [ 480, 500 ],
+            "radius": 40
+        }
+    ],
+    "arcs": [
+        {
+            // top right curve
+            "position": [ 440, 200 ],
+            "radius": 200,
+            "start_angle": 0,
+            "end_angle": 95,
+            "surface": "INSIDE"
+        },
+        {
+            // top left curve
+            "position": [ 160, 240 ],
+            "radius": 160,
+            "start_angle": 95,
+            "end_angle": 180,
+            "surface": "INSIDE"
+        }
+    ],
+    "rails": [
+        // left wall
+        {
+            "start": [ 0, 240 ],
+            "end": [ 0, 1200 ]
+        },
+        // right wall
+        {
+            "start": [ 630, 1200 ],
+            "end": [ 630, 160 ]
+        },
+        // top roof
+        {
+            "start": [ 412, 1 ],
+            "end": [ 137, 81 ]
+        },
+        // left wall fixture
+        {
+            "start": [ 0, 400 ],
+            "end": [ 80, 480 ]
+        },
+        {
+            "start": [ 80, 480 ],
+            "end": [ 80, 660 ]
+        },
+        {
+            "start": [ 80, 660 ],
+            "end": [ 0, 720 ],
+            "bounce": 1.08
+        },
+        // left bottom rail
+        {
+            "start": [ 70, 900 ],
+            "end": [ 70, 1150 ],
+            "double_sided": true
+        },
+        {
+            "start": [ 70, 1150 ],
+            "end": [ 130, 1180 ],
+            "double_sided": true
+        },
+        // right bottom rail
+        {
+            "start": [ 560, 1150 ],
+            "end": [ 560, 900 ],
+            "double_sided": true
+        },
+        {
+            "start": [ 490, 1180 ],
+            "end": [ 560, 1150 ],
+            "double_sided": true
+        }
+    ],
+    "rollovers": [
+        {
+            "position": [ 200, 800 ],
+            "symbol": "Z"
+        },
+        {
+            "position": [ 280, 770 ],
+            "symbol": "E"
+        },
+        {
+            "position": [ 360, 770 ],
+            "symbol": "R"
+        },
+        {
+            "position": [ 440, 800 ],
+            "symbol": "O"
+        }
+    ]
+}

+ 77 - 0
assets/tables/03_El Ocho.json

@@ -0,0 +1,77 @@
+{
+    "name": "El Ocho",
+    "lives": 3,
+    "balls": [
+        {
+            "position": [ 580, 580 ],
+            "velocity": [ -9.0, -0.2 ]
+        }
+    ],
+    "flippers": [
+        {
+            "position": [ 130, 1200 ],
+            "side": "LEFT",
+            "size": 125
+        },
+        {
+            "position": [ 490, 1200 ],
+            "side": "RIGHT",
+            "size": 125
+        }
+    ],
+    "arcs": [
+        {
+            // top
+            "position": [ 320, 330 ],
+            "radius": 310,
+            "start_angle": 290,
+            "end_angle": 250, // 250 / 610
+            "surface": "INSIDE"
+        },
+        {
+            // bottom left
+            "position": [ 320, 920 ],
+            "radius": 310,
+            "start_angle": 110,
+            "end_angle": 240,
+            "surface": "INSIDE"
+        },
+        {
+            // bottom right
+            "position": [ 320, 920 ],
+            "radius": 310,
+            "start_angle": 300,
+            "end_angle": 70,
+            "surface": "INSIDE"
+        },
+        {
+            "position": [ 320, 330 ],
+            "radius": 80,
+            "start_angle": 180,
+            "end_angle": 360,
+            "bounce": 1.1
+        },
+        {
+            "position": [ 320, 920 ],
+            "radius": 80,
+            "start_angle": 0,
+            "end_angle": 180,
+            "bounce": 1.1
+        }
+
+    ],
+    "portals": [
+        {
+            "a_start": [ 400, 920 ],
+            "a_end": [ 240, 920 ],
+            "b_start": [ 240, 330 ],
+            "b_end": [ 400, 330 ]
+        }
+    ],
+    "bumpers": [
+        {
+            "position": [ 320, 220 ],
+            "radius": 30
+        }
+    ]
+}

+ 114 - 0
assets/tables/04_Chamber.json

@@ -0,0 +1,114 @@
+{
+    "lives_position": [ 290, 20 ],
+    "balls": [
+        {
+            "position": [ 390, 400 ]
+        }
+    ],
+    "flippers": [
+        {
+            "position": [ 20, 1160 ],
+            "side": "LEFT",
+            "size": 220
+        },
+        {
+            "position": [ 610, 1160 ],
+            "side": "RIGHT",
+            "size": 220
+        }
+    ],
+    "arcs": [
+        {
+            "position": [ 147, 200 ],
+            "radius": 200,
+            "start_angle": 30,
+            "end_angle": 90,
+            "surface": "INSIDE"
+        },
+        {
+            "position": [ 493, 200 ],
+            "radius": 200,
+            "start_angle": 90,
+            "end_angle": 150,
+            "surface": "INSIDE"
+        },
+        {
+            "position": [ 147, 160 ],
+            "radius": 200,
+            "start_angle": 270,
+            "end_angle": 330,
+            "surface": "INSIDE"
+        },
+        {
+            "position": [ 493, 160 ],
+            "radius": 200,
+            "start_angle": 210,
+            "end_angle": 270,
+            "surface": "INSIDE"
+        }
+    ],
+    "rails": [
+        {
+            "start": [ 0, 0 ],
+            "end": [ 147, 0 ]
+        },
+        {
+            "start": [ 493, 0 ],
+            "end": [ 630, 0 ]
+        },
+        {
+            "start": [ 0, 360 ],
+            "end": [ 147, 360 ]
+        },
+        {
+            "start": [ 493, 360 ],
+            "end": [ 630, 360 ]
+        },
+        {
+            "start": [ 0, 360 ],
+            "end": [ 0, 1160 ]
+        },
+        {
+            "start": [ 630, 1160 ],
+            "end": [ 630, 360 ]
+        },
+        {
+            "start": [ 630, 360 ],
+            "end": [ 0, 360 ]
+        },
+        {
+            "start": [ 0, 60 ],
+            "end": [ 0, 300 ]
+        },
+        {
+            "start": [ 630, 300 ],
+            "end": [ 630, 60 ]
+        }
+    ],
+    "portals": [
+        {
+            "a_start": [ 0, 300 ],
+            "a_end": [ 0, 360 ],
+            "b_start": [ 0, 1000 ],
+            "b_end": [ 120, 1000 ]
+        },
+        {
+            "a_start": [ 630, 360 ],
+            "a_end": [ 630, 300 ],
+            "b_start": [ 260, 500 ],
+            "b_end": [ 160, 500 ]
+        },
+        {
+            "a_start": [ 0, 0 ],
+            "a_end": [ 0, 60 ],
+            "b_start": [ 340, 800 ],
+            "b_end": [ 460, 800 ]
+        },
+        {
+            "a_start": [ 630, 60 ],
+            "a_end": [ 630, 0 ],
+            "b_start": [ 630, 700 ],
+            "b_end": [ 510, 620 ]
+        }
+    ]
+}

+ 82 - 0
assets/tables/05_Endless.json

@@ -0,0 +1,82 @@
+{
+    "name": "Endless",
+    "lives": 1,
+    "balls": [
+        {
+            "position": [ 600, 510 ],
+            "velocity": [ 0, -10.0 ]
+        }
+    ],
+    "flippers": [
+        {
+            "position": [ 20, 1160 ],
+            "side": "LEFT",
+            "size": 240
+        },
+        {
+            "position": [ 610, 1160 ],
+            "side": "RIGHT",
+            "size": 240
+        }
+    ],
+    "bumpers": [
+        {
+            "position": [ 200, 260 ],
+            "radius": 80
+        },
+        {
+            "position": [ 450, 200 ],
+            "radius": 60
+        },
+        {
+            "position": [ 300, 750 ],
+            "radius": 50
+        },
+        {
+            "position": [ 460, 500 ],
+            "radius": 60
+        },
+        {
+            "position": [ -30.0, 600 ],
+            "radius": 130
+        }
+    ],
+    "arcs": [
+        {
+            // top right curve
+            "position": [ 430, 200 ],
+            "radius": 200,
+            "start_angle": 0,
+            "end_angle": 90,
+            "surface": "INSIDE"
+        },
+        {
+            // top left curve
+            "position": [ 200, 200 ],
+            "radius": 200,
+            "start_angle": 90,
+            "end_angle": 180,
+            "surface": "INSIDE"
+        }
+    ],
+    "rails": [
+        // left wall
+        {
+            "start": [ 0, 200 ],
+            "end": [ 0, 1160 ]
+        },
+        // right wall
+        {
+            "start": [ 630, 1160 ],
+            "end": [ 630, 200 ]
+        }
+    ],
+    "portals": [
+        {
+            "a_start": [ 200, 1270 ],
+            "a_end": [ 440, 1270 ],
+            "b_start": [ 440, 0 ],
+            "b_end": [ 200, 0 ]
+        }
+    ]
+}

+ 17 - 0
assets/tables/40_ex Arc Test.json

@@ -0,0 +1,17 @@
+{
+    "lives": 1,
+    "balls": [
+        {
+            "position": [ 50, 140 ]
+        }
+    ],
+    "arcs": [
+        {
+            "position": [ 320, 800 ],
+            "radius": 310,
+            "start_angle": 90,
+            "end_angle": 360,
+            "surface": "INSIDE"
+        }
+    ]
+}

+ 65 - 0
assets/tables/50_ex Bumpers.json

@@ -0,0 +1,65 @@
+{
+    "lives": 3,
+    "balls": [
+        {
+            "position": [
+                250,
+                50
+            ],
+            "radius": 20
+        }
+    ],
+    "flippers": [
+        {
+            "position": [ 170, 1080 ],
+            "side": "LEFT",
+            "size": 120
+        },
+        {
+            "position": [ 470, 1080 ],
+            "side": "RIGHT",
+            "size": 120
+        }
+    ],
+    "bumpers": [
+        {
+            "position": [ 200, 260 ],
+            "radius": 60
+        },
+        {
+            "position": [ 450, 200 ],
+            "radius": 60
+        },
+        {
+            "position": [ 280, 550 ],
+            "radius": 80
+        },
+        {
+            "position": [ 480, 500 ],
+            "radius": 85
+        }
+    ],
+    "rails": [
+        // left wall
+        {
+            "start": [ 0, 0 ],
+            "end": [ 0, 1080 ]
+        },
+        // right wall
+        {
+            "start": [ 630, 1080 ],
+            "end": [ 630, 0 ]
+        },
+        // bottom left
+        {
+            "start": [ 0, 1080 ],
+            "end": [ 220, 1180 ]
+
+        },
+        // bottom right
+        {
+            "start": [ 420, 1180 ],
+            "end": [ 630, 1080 ]
+        }
+    ]
+}

+ 51 - 0
assets/tables/70_ex Platforms.json

@@ -0,0 +1,51 @@
+{
+    "name": "Platforms",
+    "lives": 3,
+    "released": false,
+    "balls": [
+        {
+            "position": [
+                220, 60
+            ]
+        }
+    ],
+    "rails": [
+        {
+            "start": [ 0, 200 ],
+            "end": [ 280, 280 ]
+        },
+        {
+            "start": [ 360, 500 ],
+            "end": [ 630, 420 ]
+        },
+        {
+            "start": [ 0, 600 ],
+            "end": [ 280, 680 ]
+        },
+        {
+            "start": [ 360, 880 ],
+            "end": [ 630, 800 ]
+        },
+        // left wall
+        {
+            "start": [ 0, 0 ],
+            "end": [ 0, 1080 ]
+        },
+        // right wall
+        {
+            "start": [ 630, 1080 ],
+            "end": [ 630, 0 ]
+        },
+        // bottom left
+        {
+            "start": [ 0, 1080 ],
+            "end": [ 220, 1180 ]
+
+        },
+        // bottom right
+        {
+            "start": [ 420, 1180 ],
+            "end": [ 630, 1080 ]
+        }
+    ]
+}

+ 17 - 0
assets/tables/99_ex Error.json

@@ -0,0 +1,17 @@
+{
+    "lives_position": [ 2, 2 ],
+    "balls": [
+        {
+            // "position": [ 50, 140 ]
+        }
+    ],
+    "arcs": [
+        {
+            "position": [ 320, 800 ],
+            "radius": 310,
+            "start_angle": 90,
+            "end_angle": 360,
+            "surface": "INSIDE"
+        }
+    ]
+}

+ 0 - 0
images/.gitkeep


BIN
images/Arcade_A.png


BIN
images/Arcade_E.png


BIN
images/Arcade_G.png


BIN
images/Arcade_M.png


BIN
images/Arcade_O.png


BIN
images/Arcade_R.png


BIN
images/Arcade_V.png


BIN
images/pinball0_logo.png


BIN
images/splash.png


+ 142 - 0
nxjson/README.md

@@ -0,0 +1,142 @@
+NXJSON
+================================
+
+Very small JSON parser written in C.
+
+## Features
+
+- Parses JSON from null-terminated string
+- Easy to use tree traversal API
+- Allows // line and /\* block \*/ comments (except before colon ':')
+- Operates on single-byte or multi-byte characters (like UTF-8), but not on wide characters
+- Unescapes string values (including Unicode codepoints & surrogates)
+- Can use custom Unicode encoder, UTF-8 encoder built in
+- Can use custom memory allocator
+- Can use custom macro to print errors
+- Test suite included
+
+## Limitations
+
+- Non-validating parser; might accept invalid JSON (eg., extra or missing commas, comments, octal or hex numeric values, etc.)
+
+## API
+
+Parsed JSON tree consists of nodes. Each node has type:
+
+    typedef enum nx_json_type {
+      NX_JSON_NULL,    // this is null value
+      NX_JSON_OBJECT,  // this is an object; properties can be found in child nodes
+      NX_JSON_ARRAY,   // this is an array; items can be found in child nodes
+      NX_JSON_STRING,  // this is a string; value can be found in text_value field
+      NX_JSON_INTEGER, // this is an integer; value can be found in int_value field
+      NX_JSON_float,  // this is a float; value can be found in dbl_value field
+      NX_JSON_BOOL     // this is a boolean; value can be found in int_value field
+    } nx_json_type;
+
+The node itself:
+
+    typedef struct nx_json {
+      nx_json_type type;       // type of json node, see above
+      const char* key;         // key of the property; for object's children only
+      const char* text_value;  // text value of STRING node
+      long int_value;          // the value of INTEGER or BOOL node
+      float dbl_value;        // the value of float node
+      int length;              // number of children of OBJECT or ARRAY
+      nx_json* child;          // points to first child
+      nx_json* next;           // points to next child
+    } nx_json;
+
+#### Parsing
+
+    const nx_json* nx_json_parse(char* text, nx_json_unicode_encoder encoder);
+
+Parses null-terminated string `text` into `nx_json` tree structure. The string is **modified in place**.
+
+Parsing ends right after retrieving first valid JSON value. Remainder of the text is not analysed.
+
+Returns `NULL` on syntax error. Error details are printed out using user-redefinable macro `NX_JSON_REPORT_ERROR(msg, ptr)`.
+
+Inside parse function `nx_json` nodes get allocated using user-redefinable macro `NX_JSON_CALLOC()` and freed by `NX_JSON_FREE(json)`.
+
+All `text_value` pointers refer to the content of original `text` string, which is modified in place to unescape and null-terminate JSON string literals.
+
+`encoder` is a function defined as follows:
+
+    int unicode_to_my_encoding(unsigned int codepoint, char* p, char** endp) { ... }
+
+Encoder takes Unicode codepoint and writes corresponding encoded value into buffer pointed by `p`. It should store pointer to the end of encoded value into `*endp`. The function should return 1 on success and 0 on error. Number of bytes written must not exceed 6.
+
+NXJSON includes sample encoder `nx_json_unicode_to_utf8`, which converts all `\uXXXX` escapes into UTF-8 sequences.
+
+In case `encoder` parameter is `NULL` all unicode escape sequences (`\uXXXX`) are ignored (remain untouched).
+
+    const nx_json* nx_json_parse_utf8(char* text);
+
+This is shortcut for `nx_json_parse(text, nx_json_unicode_to_utf8)` where `nx_json_unicode_to_utf8` is unicode to UTF-8 encoder provided by NXJSON.
+
+    void nx_json_free(const nx_json* js);
+
+Frees resources (`nx_json` nodes) allocated by `nx_json_parse()`.
+
+#### Traversal
+
+    const nx_json* nx_json_get(const nx_json* json, const char* key);
+
+Gets object's property by key.
+
+If `json` points to `OBJECT` node returns first the object's property identified by key `key`.
+
+If there is no such property returns *dummy* node of type `NX_JSON_NULL`. Never returns literal `NULL`.
+
+    const nx_json* nx_json_item(const nx_json* json, int idx);
+
+Gets array's item by its index.
+
+If `json` points to `ARRAY` node returns array's element identified by index `idx`.
+
+If `json` points to `OBJECT` node returns object's property identified by index `idx`.
+
+If there is no such item/property returns *dummy* node of type `NX_JSON_NULL`. Never returns literal `NULL`.
+
+## Usage Example
+
+JSON code:
+
+    {
+      "some-int": 195,
+      "array": [ 3, 5.1, -7, "nine", /*11*/ ],
+      "some-bool": true,
+      "some-dbl": -1e-4,
+      "some-null": null,
+      "hello": "world!",
+      //"other": "/OTHER/",
+      "obj": {"KEY": "VAL"}
+    }
+
+C API:
+
+    const nx_json* json=nx_json_parse(code, 0);
+    if (json) {
+      printf("some-int=%ld\n", nx_json_get(json, "some-int")->int_value);
+      printf("some-dbl=%lf\n", nx_json_get(json, "some-dbl")->dbl_value);
+      printf("some-bool=%s\n", nx_json_get(json, "some-bool")->int_value? "true":"false");
+      printf("some-null=%s\n", nx_json_get(json, "some-null")->text_value);
+      printf("hello=%s\n", nx_json_get(json, "hello")->text_value);
+      printf("other=%s\n", nx_json_get(json, "other")->text_value);
+      printf("KEY=%s\n", nx_json_get(nx_json_get(json, "obj"), "KEY")->text_value);
+      const nx_json* arr=nx_json_get(json, "array");
+      int i;
+      for (i=0; i<arr->length; i++) {
+        const nx_json* item=nx_json_item(arr, i);
+        printf("arr[%d]=(%d) %ld %lf %s\n", i, (int)item->type, item->int_value, item->dbl_value, item->text_value);
+      }
+      nx_json_free(json);
+    }
+
+## License
+
+LGPL v3
+
+## Copyright
+
+Copyright (c) 2013 Yaroslav Stavnichiy <yarosla@gmail.com>

+ 414 - 0
nxjson/nxjson.c

@@ -0,0 +1,414 @@
+/*
+ * Copyright (c) 2013 Yaroslav Stavnichiy <yarosla@gmail.com>
+ *
+ * This file is part of NXJSON.
+ *
+ * NXJSON is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License
+ * as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * NXJSON is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with NXJSON. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+// this file can be #included in your code
+#ifndef NXJSON_C
+#define NXJSON_C
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include <stdlib.h>
+#include <stdio.h>
+#include <string.h>
+#include <assert.h>
+#include <errno.h>
+#include <furi.h>
+
+#include "nxjson.h"
+
+// redefine NX_JSON_CALLOC & NX_JSON_FREE to use custom allocator
+#ifndef NX_JSON_CALLOC
+#define NX_JSON_CALLOC()   calloc(1, sizeof(nx_json))
+#define NX_JSON_FREE(json) free((void*)(json))
+#endif
+
+// redefine NX_JSON_REPORT_ERROR to use custom error reporting
+#ifndef NX_JSON_REPORT_ERROR
+#define NX_JSON_REPORT_ERROR(msg, p) FURI_LOG_E("nxjson", "PARSE ERROR (%d): at %s", __LINE__, p)
+#endif
+
+#define IS_WHITESPACE(c) ((unsigned char)(c) <= (unsigned char)' ')
+
+static nx_json* create_json(nx_json_type type, const char* key, nx_json* parent) {
+    nx_json* js = NX_JSON_CALLOC();
+    assert(js);
+    js->type = type;
+    js->key = key;
+    if(!parent->children.last) {
+        parent->children.first = parent->children.last = js;
+    } else {
+        parent->children.last->next = js;
+        parent->children.last = js;
+    }
+    parent->children.length++;
+    return js;
+}
+
+void nx_json_free(const nx_json* js) {
+    if(!js) {
+        return;
+    }
+    if(js->type == NX_JSON_OBJECT || js->type == NX_JSON_ARRAY) {
+        nx_json* p = js->children.first;
+        nx_json* p1;
+        while(p) {
+            p1 = p->next;
+            nx_json_free(p);
+            p = p1;
+        }
+    }
+    NX_JSON_FREE(js);
+}
+
+static int unicode_to_utf8(unsigned int codepoint, char* p, char** endp) {
+    // code from http://stackoverflow.com/a/4609989/697313
+    if(codepoint < 0x80)
+        *p++ = codepoint;
+    else if(codepoint < 0x800)
+        *p++ = 192 + codepoint / 64, *p++ = 128 + codepoint % 64;
+    else if(codepoint - 0xd800u < 0x800)
+        return 0; // surrogate must have been treated earlier
+    else if(codepoint < 0x10000)
+        *p++ = 224 + codepoint / 4096, *p++ = 128 + codepoint / 64 % 64,
+        *p++ = 128 + codepoint % 64;
+    else if(codepoint < 0x110000)
+        *p++ = 240 + codepoint / 262144, *p++ = 128 + codepoint / 4096 % 64,
+        *p++ = 128 + codepoint / 64 % 64, *p++ = 128 + codepoint % 64;
+    else
+        return 0; // error
+    *endp = p;
+    return 1;
+}
+
+nx_json_unicode_encoder nx_json_unicode_to_utf8 = unicode_to_utf8;
+
+static inline int hex_val(char c) {
+    if(c >= '0' && c <= '9') return c - '0';
+    if(c >= 'a' && c <= 'f') return c - 'a' + 10;
+    if(c >= 'A' && c <= 'F') return c - 'A' + 10;
+    return -1;
+}
+
+static char* unescape_string(char* s, char** end, nx_json_unicode_encoder encoder) {
+    char* p = s;
+    char* d = s;
+    char c;
+    while((c = *p++)) {
+        if(c == '"') {
+            *d = '\0';
+            *end = p;
+            return s;
+        } else if(c == '\\') {
+            switch(*p) {
+            case '\\':
+            case '/':
+            case '"':
+                *d++ = *p++;
+                break;
+            case 'b':
+                *d++ = '\b';
+                p++;
+                break;
+            case 'f':
+                *d++ = '\f';
+                p++;
+                break;
+            case 'n':
+                *d++ = '\n';
+                p++;
+                break;
+            case 'r':
+                *d++ = '\r';
+                p++;
+                break;
+            case 't':
+                *d++ = '\t';
+                p++;
+                break;
+            case 'u': // unicode
+                if(!encoder) {
+                    // leave untouched
+                    *d++ = c;
+                    break;
+                }
+                char* ps = p - 1;
+                int h1, h2, h3, h4;
+                if((h1 = hex_val(p[1])) < 0 || (h2 = hex_val(p[2])) < 0 ||
+                   (h3 = hex_val(p[3])) < 0 || (h4 = hex_val(p[4])) < 0) {
+                    NX_JSON_REPORT_ERROR("invalid unicode escape", p - 1);
+                    return 0;
+                }
+                unsigned int codepoint = h1 << 12 | h2 << 8 | h3 << 4 | h4;
+                if((codepoint & 0xfc00) ==
+                   0xd800) { // high surrogate; need one more unicode to succeed
+                    p += 6;
+                    if(p[-1] != '\\' || *p != 'u' || (h1 = hex_val(p[1])) < 0 ||
+                       (h2 = hex_val(p[2])) < 0 || (h3 = hex_val(p[3])) < 0 ||
+                       (h4 = hex_val(p[4])) < 0) {
+                        NX_JSON_REPORT_ERROR("invalid unicode surrogate", ps);
+                        return 0;
+                    }
+                    unsigned int codepoint2 = h1 << 12 | h2 << 8 | h3 << 4 | h4;
+                    if((codepoint2 & 0xfc00) != 0xdc00) {
+                        NX_JSON_REPORT_ERROR("invalid unicode surrogate", ps);
+                        return 0;
+                    }
+                    codepoint = 0x10000 + ((codepoint - 0xd800) << 10) + (codepoint2 - 0xdc00);
+                }
+                if(!encoder(codepoint, d, &d)) {
+                    NX_JSON_REPORT_ERROR("invalid codepoint", ps);
+                    return 0;
+                }
+                p += 5;
+                break;
+            default:
+                // leave untouched
+                *d++ = c;
+                break;
+            }
+        } else {
+            *d++ = c;
+        }
+    }
+    NX_JSON_REPORT_ERROR("no closing quote for string", s);
+    return 0;
+}
+
+static char* skip_block_comment(char* p) {
+    // assume p[-2]=='/' && p[-1]=='*'
+    char* ps = p - 2;
+    if(!*p) {
+        NX_JSON_REPORT_ERROR("endless comment", ps);
+        return 0;
+    }
+REPEAT:
+    p = strchr(p + 1, '/');
+    if(!p) {
+        NX_JSON_REPORT_ERROR("endless comment", ps);
+        return 0;
+    }
+    if(p[-1] != '*') goto REPEAT;
+    return p + 1;
+}
+
+static char* parse_key(const char** key, char* p, nx_json_unicode_encoder encoder) {
+    // on '}' return with *p=='}'
+    char c;
+    while((c = *p++)) {
+        if(c == '"') {
+            *key = unescape_string(p, &p, encoder);
+            if(!*key) return 0; // propagate error
+            while(*p && IS_WHITESPACE(*p))
+                p++;
+            if(*p == ':') return p + 1;
+            NX_JSON_REPORT_ERROR("unexpected chars", p);
+            return 0;
+        } else if(IS_WHITESPACE(c) || c == ',') {
+            // continue
+        } else if(c == '}') {
+            return p - 1;
+        } else if(c == '/') {
+            if(*p == '/') { // line comment
+                char* ps = p - 1;
+                p = strchr(p + 1, '\n');
+                if(!p) {
+                    NX_JSON_REPORT_ERROR("endless comment", ps);
+                    return 0; // error
+                }
+                p++;
+            } else if(*p == '*') { // block comment
+                p = skip_block_comment(p + 1);
+                if(!p) return 0;
+            } else {
+                NX_JSON_REPORT_ERROR("unexpected chars", p - 1);
+                return 0; // error
+            }
+        } else {
+            NX_JSON_REPORT_ERROR("unexpected chars", p - 1);
+            return 0; // error
+        }
+    }
+    NX_JSON_REPORT_ERROR("unexpected chars", p - 1);
+    return 0; // error
+}
+
+static char*
+    parse_value(nx_json* parent, const char* key, char* p, nx_json_unicode_encoder encoder) {
+    nx_json* js;
+    while(1) {
+        switch(*p) {
+        case '\0':
+            NX_JSON_REPORT_ERROR("unexpected end of text", p);
+            return 0; // error
+        case ' ':
+        case '\t':
+        case '\n':
+        case '\r':
+        case ',':
+            // skip
+            p++;
+            break;
+        case '{':
+            js = create_json(NX_JSON_OBJECT, key, parent);
+            p++;
+            while(1) {
+                const char* new_key = NULL;
+                p = parse_key(&new_key, p, encoder);
+                if(!p) return 0; // error
+                if(*p == '}') return p + 1; // end of object
+                p = parse_value(js, new_key, p, encoder);
+                if(!p) return 0; // error
+            }
+        case '[':
+            js = create_json(NX_JSON_ARRAY, key, parent);
+            p++;
+            while(1) {
+                p = parse_value(js, 0, p, encoder);
+                if(!p) return 0; // error
+                if(*p == ']') return p + 1; // end of array
+            }
+        case ']':
+            return p;
+        case '"':
+            p++;
+            js = create_json(NX_JSON_STRING, key, parent);
+            js->text_value = unescape_string(p, &p, encoder);
+            if(!js->text_value) return 0; // propagate error
+            return p;
+        case '-':
+        case '0':
+        case '1':
+        case '2':
+        case '3':
+        case '4':
+        case '5':
+        case '6':
+        case '7':
+        case '8':
+        case '9': {
+            js = create_json(NX_JSON_INTEGER, key, parent);
+            char* pe;
+            if(*p == '-') {
+                js->num.s_value = (nxjson_s64)strtol(p, &pe, 0); // was strtoll
+            } else {
+                js->num.u_value = (nxjson_u64)strtoul(p, &pe, 0); // was stroull
+            }
+            if(pe == p || errno == ERANGE) {
+                NX_JSON_REPORT_ERROR("invalid number", p);
+                return 0; // error
+            }
+            if(*pe == '.' || *pe == 'e' || *pe == 'E') { // float value
+                js->type = NX_JSON_float;
+                js->num.dbl_value = strtod(p, &pe);
+                if(pe == p || errno == ERANGE) {
+                    NX_JSON_REPORT_ERROR("invalid number", p);
+                    return 0; // error
+                }
+            } else {
+                if(*p == '-') {
+                    js->num.dbl_value = js->num.s_value;
+                } else {
+                    js->num.dbl_value = js->num.u_value;
+                }
+            }
+            return pe;
+        }
+        case 't':
+            if(!strncmp(p, "true", 4)) {
+                js = create_json(NX_JSON_BOOL, key, parent);
+                js->num.u_value = 1;
+                return p + 4;
+            }
+            NX_JSON_REPORT_ERROR("unexpected chars", p);
+            return 0; // error
+        case 'f':
+            if(!strncmp(p, "false", 5)) {
+                js = create_json(NX_JSON_BOOL, key, parent);
+                js->num.u_value = 0;
+                return p + 5;
+            }
+            NX_JSON_REPORT_ERROR("unexpected chars", p);
+            return 0; // error
+        case 'n':
+            if(!strncmp(p, "null", 4)) {
+                create_json(NX_JSON_NULL, key, parent);
+                return p + 4;
+            }
+            NX_JSON_REPORT_ERROR("unexpected chars", p);
+            return 0; // error
+        case '/': // comment
+            if(p[1] == '/') { // line comment
+                char* ps = p;
+                p = strchr(p + 2, '\n');
+                if(!p) {
+                    NX_JSON_REPORT_ERROR("endless comment", ps);
+                    return 0; // error
+                }
+                p++;
+            } else if(p[1] == '*') { // block comment
+                p = skip_block_comment(p + 2);
+                if(!p) return 0;
+            } else {
+                NX_JSON_REPORT_ERROR("unexpected chars", p);
+                return 0; // error
+            }
+            break;
+        default:
+            NX_JSON_REPORT_ERROR("unexpected chars", p);
+            return 0; // error
+        }
+    }
+}
+
+const nx_json* nx_json_parse_utf8(char* text) {
+    return nx_json_parse(text, unicode_to_utf8);
+}
+
+const nx_json* nx_json_parse(char* text, nx_json_unicode_encoder encoder) {
+    nx_json js = {0};
+    if(!parse_value(&js, 0, text, encoder)) {
+        if(js.children.first) nx_json_free(js.children.first);
+        return 0;
+    }
+    return js.children.first;
+}
+
+const nx_json* nx_json_get(const nx_json* json, const char* key) {
+    nx_json* js;
+    for(js = json->children.first; js; js = js->next) {
+        if(js->key && !strcmp(js->key, key)) return js;
+    }
+    return NULL;
+}
+
+const nx_json* nx_json_item(const nx_json* json, int idx) {
+    nx_json* js;
+    for(js = json->children.first; js; js = js->next) {
+        if(!idx--) return js;
+    }
+    return NULL;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* NXJSON_C */

+ 89 - 0
nxjson/nxjson.h

@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2013 Yaroslav Stavnichiy <yarosla@gmail.com>
+ *
+ * This file is part of NXJSON.
+ *
+ * NXJSON is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License
+ * as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * NXJSON is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with NXJSON. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#ifndef NXJSON_H
+#define NXJSON_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#ifndef NXJSON_TYPE_U64
+
+#include <stdint.h>
+
+typedef uint64_t nxjson_u64;
+#endif
+
+#ifndef NXJSON_TYPE_S64
+
+#include <stdint.h>
+
+typedef uint64_t nxjson_s64;
+#endif
+
+typedef enum nx_json_type {
+    NX_JSON_NULL, // this is null value
+    NX_JSON_OBJECT, // this is an object; properties can be found in child nodes
+    NX_JSON_ARRAY, // this is an array; items can be found in child nodes
+    NX_JSON_STRING, // this is a string; value can be found in text_value field
+    NX_JSON_INTEGER, // this is an integer; value can be found in int_value field
+    NX_JSON_float, // this is a float; value can be found in dbl_value field
+    NX_JSON_BOOL // this is a boolean; value can be found in int_value field
+} nx_json_type;
+
+typedef struct nx_json {
+    nx_json_type type; // type of json node, see above
+    const char* key; // key of the property; for object's children only
+    union {
+        const char* text_value; // text value of STRING node
+        struct {
+            union {
+                nxjson_u64 u_value; // the value of INTEGER or BOOL node
+                nxjson_s64 s_value;
+            };
+            float dbl_value; // the value of float node
+        } num;
+        struct { // children of OBJECT or ARRAY
+            int length;
+            struct nx_json* first;
+            struct nx_json* last;
+        } children;
+    };
+    struct nx_json* next; // points to next child
+} nx_json;
+
+typedef int (*nx_json_unicode_encoder)(unsigned int codepoint, char* p, char** endp);
+
+extern nx_json_unicode_encoder nx_json_unicode_to_utf8;
+
+const nx_json* nx_json_parse(char* text, nx_json_unicode_encoder encoder);
+
+const nx_json* nx_json_parse_utf8(char* text);
+
+void nx_json_free(const nx_json* js);
+
+const nx_json* nx_json_get(const nx_json* json, const char* key); // get object's property by key
+const nx_json* nx_json_item(const nx_json* json, int idx); // get array element by index
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* NXJSON_H */

+ 673 - 0
objects.cxx

@@ -0,0 +1,673 @@
+#include <furi.h>
+#include <gui/gui.h>
+
+#include "objects.h"
+#include "pinball0.h"
+
+Object::Object(const Vec2& p_, float r_)
+    : p(p_)
+    , prev_p(p_)
+    , a({0.0, 0.0})
+    , r(r_)
+    , physical(true)
+    , bounce(1.0f)
+    , fixed(false)
+    , score(0) {
+}
+
+void Object::update(float dt) {
+    if(fixed) {
+        return;
+    }
+    Vec2 velocity = p - prev_p;
+    // table friction / damping
+    velocity *= 0.9999f;
+    prev_p = p;
+    p = p + velocity + a + (dt * dt);
+    a = {0.0, 0.0};
+}
+
+void Ball::draw(Canvas* canvas) {
+    canvas_draw_disc(canvas, p.x / 10.0f, p.y / 10.0f, r / 10.0f);
+}
+
+Flipper::Flipper(const Vec2& p_, Side side_, size_t size_)
+    : p(p_)
+    , side(side_)
+    , size(size_)
+    , r(20.0f)
+    , max_rotation(1.0f)
+    , omega(4.0f)
+    , rotation(0.0f)
+    , powered(false) {
+    if(side_ == Side::LEFT) {
+        rest_angle = -0.4f;
+        sign = 1;
+    } else {
+        rest_angle = M_PI + 0.4;
+        sign = -1;
+    }
+}
+
+void Flipper::draw(Canvas* canvas) {
+    // base / pivot
+    canvas_draw_circle(canvas, p.x / 10, p.y / 10, r / 10);
+
+    // tip
+    float angle = rest_angle + sign * rotation;
+    Vec2 dir(cos(angle), -sin(angle));
+
+    // draw the tip
+    Vec2 tip = p + dir * size;
+    canvas_draw_circle(canvas, tip.x / 10, tip.y / 10, r / 10);
+
+    // top and bottom lines
+    Vec2 perp(-dir.y, dir.x);
+    perp.normalize();
+    Vec2 start = p + perp * r;
+    Vec2 end = start + dir * size;
+    canvas_draw_line(canvas, start.x / 10, start.y / 10, end.x / 10, end.y / 10);
+
+    perp *= -1.0f;
+    start = p + perp * r;
+    end = start + dir * size;
+    canvas_draw_line(canvas, start.x / 10, start.y / 10, end.x / 10, end.y / 10);
+}
+
+void Flipper::update(float dt) {
+    float prev_rotation = rotation;
+    if(powered) {
+        rotation = fmin(rotation + dt * omega, max_rotation);
+    } else {
+        rotation = fmax(rotation - dt * omega, 0.0f);
+    }
+    current_omega = sign * (rotation - prev_rotation) / dt;
+}
+
+bool Flipper::collide(Ball& ball) {
+    Vec2 closest = Vec2_closest(p, get_tip(), ball.p);
+    Vec2 dir = ball.p - closest;
+    float dist = dir.mag();
+    if(dist <= VEC2_EPSILON || dist > ball.r + r) {
+        return false;
+    }
+    dir = dir / dist;
+
+    Vec2 ball_v = ball.p - ball.prev_p;
+
+    // adjust ball position
+    float corr = ball.r + r - dist;
+    ball.p += dir * corr;
+
+    closest += dir * r;
+    closest -= p;
+    Vec2 perp(-closest.y, closest.x);
+    perp *= -1.0f;
+    perp.normalize();
+    Vec2 surface_velocity = perp * 1.7f; // TODO: flipper power??
+    FURI_LOG_I(TAG, "sv: %.3f,%.3f", (double)surface_velocity.x, (double)surface_velocity.y);
+    if(current_omega != 0.0f) surface_velocity *= current_omega;
+    FURI_LOG_I(TAG, "sv: %.3f,%.3f", (double)surface_velocity.x, (double)surface_velocity.y);
+
+    // TODO: Flippers currently aren't "bouncy" when they are still
+    float v = ball_v.dot(dir);
+    float v_new = surface_velocity.dot(dir);
+    FURI_LOG_I(TAG, "v_new: %.4f, v: %.4f", (double)v_new, (double)v);
+    ball_v += dir * (v_new - v);
+    ball.prev_p = ball.p - ball_v;
+    return true;
+}
+
+Vec2 Flipper::get_tip() const {
+    float angle = rest_angle + sign * rotation;
+    Vec2 dir(cos(angle), -sin(angle));
+    Vec2 tip = p + dir * size;
+    return tip;
+}
+
+void Polygon::draw(Canvas* canvas) {
+    if(!hidden) {
+        for(size_t i = 0; i < points.size() - 1; i++) {
+            canvas_draw_line(
+                canvas,
+                points[i].x / 10,
+                points[i].y / 10,
+                points[i + 1].x / 10,
+                points[i + 1].y / 10);
+
+#ifdef DRAW_NORMALS
+            Vec2 c = (points[i] + points[i + 1]) / 2.0f;
+            Vec2 e = c + normals[i] * 40.0f;
+            canvas_draw_line(canvas, c.x / 10, c.y / 10, e.x / 10, e.y / 10);
+#endif
+        }
+    }
+}
+
+// Attempt to handle double_sided rails better
+bool Polygon::collide(Ball& ball) {
+    Vec2 ball_v = ball.p - ball.prev_p;
+    Vec2 dir;
+    Vec2 closest = points[0];
+    Vec2 normal = normals[0];
+    float min_dist = infinityf();
+
+    for(size_t i = 0; i < points.size() - 1; i++) {
+        Vec2& p1 = points[i];
+        Vec2& p2 = points[i + 1];
+
+        Vec2 c = Vec2_closest(p1, p2, ball.p);
+        dir = ball.p - c;
+        float dist = dir.mag();
+        if(dist < min_dist) {
+            min_dist = dist;
+            closest = c;
+            normal = normals[i];
+        }
+    }
+    dir = ball.p - closest;
+    float dist = dir.mag();
+    if(dist > ball.r) {
+        return false;
+    }
+
+    if(dist <= VEC2_EPSILON) {
+        dir = normal;
+        dist = normal.mag();
+    }
+    dir = dir / dist;
+    if(dir.dot(normal) >= 0.0f) {
+        // FURI_LOG_I(TAG, "Collision Moving TOWARDS");
+        ball.p += dir * (ball.r - dist);
+    } else {
+        // TODO: This is key - we're moving away, so don't alter our v / prev_p!
+        // FURI_LOG_I(TAG, "Collision Moving AWAY");
+        return false;
+        // ball.p += dir * -(dist + ball.r);
+    }
+
+    // FURI_LOG_I(
+    //     TAG,
+    //     "p: %.3f,%.3f  dir: %.3f,%.3f  norm: %.3f,%.3f",
+    //     (double)ball.p.x,
+    //     (double)ball.p.y,
+    //     (double)dir.x,
+    //     (double)dir.y,
+    //     (double)normal.x,
+    //     (double)normal.y);
+    float v = ball_v.dot(dir);
+    float v_new = fabs(v) * bounce;
+    ball_v += dir * (v_new - v);
+    ball.prev_p = ball.p - ball_v;
+    return true;
+}
+
+// Works-ish - 11/5/2024
+// bool Polygon::collide(Ball& ball) {
+//     Vec2 ball_v = ball.p - ball.prev_p;
+//     // We need to check for collisions across all line segments
+//     for(size_t i = 0; i < points.size() - 1; i++) {
+//         // If ball is moving away from the line, we can't have a collision!
+//         if(normals[i].dot(ball_v) > 0) {
+//             continue;
+//         }
+
+//         Vec2& p1 = points[i];
+//         Vec2& p2 = points[i + 1];
+//         // bool isLeft_prev = Vec2_ccw(p1, p2, ball.prev_p);
+//         // bool isLeft = Vec2_ccw(p1, p2, ball.p);
+//         Vec2 closest = Vec2_closest(p1, p2, ball.p);
+//         float dist = ball.p.dist(closest);
+
+//         if(dist < ball.r) {
+//             // FURI_LOG_I(TAG, "... within collision distance!");
+//             // ball_v.dot
+
+//             // float factor = (ball.r - dist) / ball.r;
+//             // ball.p -= normals[i] * factor;
+//             float depth = ball.r - dist;
+//             ball.p -= normals[i] * depth * 1.05f;
+
+//             Vec2 rel_v = ball_v * -1;
+//             float velAlongNormal = rel_v.dot(normals[i]);
+//             float j = (-(1 + 1) * velAlongNormal);
+//             Vec2 impulse = j * normals[i];
+//             ball_v -= impulse;
+
+//             ball.prev_p = ball.p - ball_v;
+//             return true;
+//         }
+//     }
+//     return false;
+// }
+
+void Polygon::finalize() {
+    if(points.size() < 2) {
+        FURI_LOG_E(TAG, "Polygon: FINALIZE_ERROR - insufficient points");
+        return;
+    }
+    // compute and store normals on all segments
+    for(size_t i = 0; i < points.size() - 1; i++) {
+        Vec2 normal(points[i + 1].y - points[i].y, points[i].x - points[i + 1].x);
+        normal.normalize();
+        normals.push_back(normal);
+    }
+}
+
+void Portal::draw(Canvas* canvas) {
+    if(!hidden) {
+        Vec2 d;
+        Vec2 e;
+
+        // Portal A
+        canvas_draw_line(canvas, a1.x / 10, a1.y / 10, a2.x / 10, a2.y / 10);
+        d = a1 + au * amag * 0.33f;
+        e = d + na * 20.0f;
+        canvas_draw_line(canvas, d.x / 10, d.y / 10, e.x / 10, e.y / 10);
+        d += au * amag * 0.33f;
+        e = d + na * 20.0f;
+        canvas_draw_line(canvas, d.x / 10, d.y / 10, e.x / 10, e.y / 10);
+
+        // Portal B
+        canvas_draw_line(canvas, b1.x / 10, b1.y / 10, b2.x / 10, b2.y / 10);
+        d = b1 + bu * bmag * 0.33f;
+        e = d + nb * 20.0f;
+        canvas_draw_line(canvas, d.x / 10, d.y / 10, e.x / 10, e.y / 10);
+        d += bu * bmag * 0.33f;
+        e = d + nb * 20.0f;
+        canvas_draw_line(canvas, d.x / 10, d.y / 10, e.x / 10, e.y / 10);
+
+        if(decay > 0) {
+            canvas_draw_circle(canvas, enter_p.x / 10, enter_p.y / 10, 2);
+        }
+    }
+#ifdef DRAW_NORMALS
+    Vec2 c = (a1 + a2) / 2.0f;
+    Vec2 e = c + na * 40.0f;
+    canvas_draw_line(canvas, c.x / 10, c.y / 10, e.x / 10, e.y / 10);
+    c = (b1 + b2) / 2.0f;
+    e = c + nb * 40.0f;
+    canvas_draw_line(canvas, c.x / 10, c.y / 10, e.x / 10, e.y / 10);
+#endif
+}
+
+bool Portal::collide(Ball& ball) {
+    Vec2 ball_v = ball.p - ball.prev_p;
+    Vec2 dir;
+    Vec2 closest;
+    Vec2 normal;
+    float dist;
+
+    Vec2 a_cl = Vec2_closest(a1, a2, ball.p);
+    dir = ball.p - a_cl;
+    dist = dir.mag();
+    dir = dir / dist;
+    if(dist <= ball.r && dir.dot(na) >= 0.0f) {
+        // entering portal a! move it to portal b
+        // how far "along" the portal are we?
+        enter_p = a_cl;
+        float offset = (a_cl - a1).mag() / amag;
+        ball.p = b2 - bu * (bmag * offset);
+        // ensure we're "outside" the next portal to prevent rapid re-entry
+        ball.p += nb * ball.r;
+
+        // get projections on entry portal
+        float m = -ball_v.dot(au); // tangent magnitude
+        float n = ball_v.dot(na); // normal magnitude
+
+        FURI_LOG_I(
+            TAG,
+            "v: %.3f,%.3f  u: %.3f,%.3f  n: %.3f,%.3f  M: %.3f  N: %.3f",
+            (double)ball_v.x,
+            (double)ball_v.y,
+            (double)au.x,
+            (double)au.y,
+            (double)na.x,
+            (double)na.y,
+            (double)m,
+            (double)n);
+
+        // transform to exit portal
+        ball_v.x = bu.x * m - nb.x * n;
+        ball_v.y = bu.y * m - nb.y * n;
+        FURI_LOG_I(TAG, "new v: %.3f,%.3f", (double)ball_v.x, (double)ball_v.y);
+
+        ball.prev_p = ball.p - ball_v;
+        return true;
+    }
+
+    Vec2 b_cl = Vec2_closest(b1, b2, ball.p);
+    dir = ball.p - b_cl;
+    dist = dir.mag();
+    dir = dir / dist;
+    if(dist <= ball.r && dir.dot(nb) >= 0.0f) {
+        // entering portal b! move it to portal a
+        // how far "along" the portal are we?
+        enter_p = b_cl;
+        float offset = (b_cl - b1).mag() / bmag;
+        ball.p = a2 - au * (amag * offset);
+        // ensure we're "outside" the next portal to prevent rapid re-entry
+        ball.p += na * ball.r;
+
+        // get projections on entry portal
+        float m = -ball_v.dot(bu); // tangent magnitude
+        float n = ball_v.dot(nb); // normal magnitude
+
+        FURI_LOG_I(
+            TAG,
+            "v: %.3f,%.3f  u: %.3f,%.3f  n: %.3f,%.3f  M: %.3f  N: %.3f",
+            (double)ball_v.x,
+            (double)ball_v.y,
+            (double)bu.x,
+            (double)bu.y,
+            (double)nb.x,
+            (double)nb.y,
+            (double)m,
+            (double)n);
+
+        // transform to exit portal
+        ball_v.x = au.x * m - na.x * n;
+        ball_v.y = au.y * m - na.y * n;
+        FURI_LOG_I(TAG, "new v: %.3f,%.3f", (double)ball_v.x, (double)ball_v.y);
+
+        ball.prev_p = ball.p - ball_v;
+        return true;
+    }
+    return false;
+}
+
+void Portal::reset_animation() {
+    decay = 8;
+}
+void Portal::step_animation() {
+    if(decay > 0) {
+        decay--;
+    } else {
+        decay = 0;
+    }
+}
+
+void Portal::finalize() {
+    na = Vec2(a2.y - a1.y, a1.x - a2.x);
+    na.normalize();
+    amag = (a2 - a1).mag();
+    au = (a2 - a1) / amag;
+    nb = Vec2(b2.y - b1.y, b1.x - b2.x);
+    nb.normalize();
+    bmag = (b2 - b1).mag();
+    bu = (b2 - b1) / bmag;
+}
+
+Arc::Arc(const Vec2& p_, float r_, float s_, float e_, Surface surf_)
+    : FixedObject()
+    , p(p_)
+    , r(r_)
+    , start(s_)
+    , end(e_)
+    , surface(surf_) {
+}
+
+void Arc::draw(Canvas* canvas) {
+    if(start == 0 && end == (float)M_PI * 2) {
+        canvas_draw_circle(canvas, p.x / 10.0f, p.y / 10.0f, r / 10.0f);
+    } else {
+        float adj_end = end;
+        if(end < start) {
+            adj_end += (float)M_PI * 2;
+        }
+        // initialize to start of arc
+        float sx = p.x + r * cosf(start);
+        float sy = p.y - r * sinf(start);
+        size_t segments = r / 8;
+        for(size_t i = 1; i <= segments; i++) { // for now, use r to determin number of segments
+            float nx = p.x + r * cosf(start + i / (segments / (adj_end - start)));
+            float ny = p.y - r * sinf(start + i / (segments / (adj_end - start)));
+            canvas_draw_line(
+                canvas, roundf(sx / 10), roundf(sy / 10), roundf(nx / 10), roundf(ny / 10));
+            sx = nx;
+            sy = ny;
+        }
+    }
+}
+
+// returns value between 0 and 2 PI
+// assumes x,y are on cartesean plane, thus you should pass it a neg y
+// since the display on flipper is y-inverted
+float vector_to_angle(float x, float y) {
+    if(x == 0) // special cases UP or DOWN
+        return (y > 0) ? M_PI_2 : (y == 0) ? 0 : M_PI + M_PI_2;
+    else if(y == 0) // special cases LEFT or RIGHT
+        return (x >= 0) ? 0 : M_PI;
+    float ret = atanf(y / x); // quadrant I
+    if(x < 0 && y < 0) // quadrant III
+        ret = (float)M_PI + ret;
+    else if(x < 0) // quadrant II
+        ret = (float)M_PI + ret; // it actually substracts
+    else if(y < 0) // quadrant IV
+        ret = (float)M_PI + (float)M_PI_2 + ((float)M_PI_2 + ret); // it actually substracts
+    return ret;
+}
+
+// Matthias research - 10 minute physics
+bool Arc::collide(Ball& ball) {
+    Vec2 dir = ball.p - p;
+    float dist = dir.mag();
+    // FURI_LOG_I(
+    //     TAG,
+    //     "ball.p: %.3f,%.3f  p: %.3f,%.3f",
+    //     (double)ball.p.x,
+    //     (double)ball.p.y,
+    //     (double)p.x,
+    //     (double)p.y);
+    // FURI_LOG_I(TAG, "dir: %.3f,%.3f  dist: %.3f", (double)dir.x, (double)dir.y, (double)dist);
+
+    if(surface == OUTSIDE) {
+        if(dist > r + ball.r) {
+            return false;
+        }
+        // FURI_LOG_I(TAG, "hitting arc");
+        float angle = vector_to_angle(dir.x, -dir.y);
+        if((start < end && start <= angle && angle <= end) ||
+           (start > end && (angle >= start || angle <= end))) {
+            // FURI_LOG_I(TAG, "colliding with arc");
+            dir.normalize();
+
+            Vec2 ball_v = ball.p - ball.prev_p;
+            float corr = ball.r + r - dist;
+            ball.p += dir * corr;
+            float v = ball_v.dot(dir);
+            ball_v += dir * (3.0f - v); // TODO: pushVel, this should be a prop
+            ball.prev_p = ball.p - ball_v;
+            return true;
+        }
+    }
+    if(surface == INSIDE) {
+        Vec2 prev_dir = ball.prev_p - p;
+        float prev_dist = prev_dir.mag();
+        if(prev_dist < r && dist + ball.r > r) {
+            // FURI_LOG_I(TAG, "Inside an arc!");
+            float angle = vector_to_angle(dir.x, -dir.y);
+            FURI_LOG_I(TAG, "%f : %f : %f", (double)start, (double)angle, (double)end);
+            // if(angle >= start && angle <= end) {
+            if((start < end && start <= angle && angle <= end) ||
+               (start > end && (angle >= start || angle <= end))) {
+                // FURI_LOG_I(TAG, "Within the arc angle");
+
+                dir.normalize();
+                Vec2 ball_v = ball.p - ball.prev_p;
+
+                // correct our position to be "on" the arc
+                float corr = dist + ball.r - r;
+                ball.p -= dir * corr;
+
+                // Adjust restitution on tangent and normals independently
+                Vec2 tangent = {-dir.y, dir.x};
+                float T = (ball_v.x * tangent.x + ball_v.y * tangent.y) * ARC_TANGENT_RESTITUTION;
+                float N = (ball_v.x * tangent.y - ball_v.y * tangent.x) * ARC_NORMAL_RESTITUTION;
+
+                ball_v.x = tangent.x * T - tangent.y * N;
+                ball_v.y = tangent.y * T + tangent.x * N;
+
+                // Current collision - works good, but handles restitution holistically
+                // float v = ball_v.dot(dir);
+                // ball_v -= dir * v * 2.0f * bounce;
+
+                ball.prev_p = ball.p - ball_v;
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+Bumper::Bumper(const Vec2& p_, float r_)
+    : Arc(p_, r_) {
+}
+
+void Bumper::draw(Canvas* canvas) {
+    Arc::draw(canvas);
+    if(decay) {
+        canvas_draw_disc(canvas, p.x / 10, p.y / 10, (r / 10) * 0.8f * (decay / 30.0f));
+    }
+}
+void Bumper::reset_animation() {
+    decay = 30;
+}
+
+void Bumper::step_animation() {
+    if(decay > 20) {
+        decay--;
+    } else {
+        decay = 0;
+    }
+}
+
+void Rollover::draw(Canvas* canvas) {
+    if(activated) {
+        canvas_draw_str_aligned(canvas, p.x / 10, p.y / 10, AlignCenter, AlignCenter, c);
+    } else {
+        canvas_draw_dot(canvas, p.x / 10, p.y / 10);
+    }
+}
+
+bool Rollover::collide(Ball& ball) {
+    Vec2 dir = ball.p - p;
+    float dist = dir.mag();
+    if(dist < 30) {
+        activated = true;
+    }
+    return false;
+}
+
+void Turbo::draw(Canvas* canvas) {
+    canvas_draw_line(
+        canvas, chevron_1[0].x / 10, chevron_1[0].y / 10, chevron_1[1].x / 10, chevron_1[1].y / 10);
+    canvas_draw_line(
+        canvas, chevron_1[1].x / 10, chevron_1[1].y / 10, chevron_1[2].x / 10, chevron_1[2].y / 10);
+
+    canvas_draw_line(
+        canvas, chevron_2[0].x / 10, chevron_2[0].y / 10, chevron_2[1].x / 10, chevron_2[1].y / 10);
+    canvas_draw_line(
+        canvas, chevron_2[1].x / 10, chevron_2[1].y / 10, chevron_2[2].x / 10, chevron_2[2].y / 10);
+}
+
+bool Turbo::collide(Ball& ball) {
+    Vec2 dir = ball.p - p;
+    float dist = dir.mag();
+    if(dist < 30) {
+        // apply the turbo in 'dir' with force of 'boost'
+        FURI_LOG_I(TAG, "TURBO! dir: %.3f,%.3f", (double)dir.x, (double)dir.y);
+        ball.accelerate(dir * (boost / 50.0f));
+    }
+    return false;
+}
+
+Plunger::Plunger(const Vec2& p_)
+    : Object(p_, 20)
+    , size(100) {
+    compression = 0;
+}
+
+void Plunger::draw(Canvas* canvas) {
+    // draw the end / striker
+    canvas_draw_circle(canvas, p.x / 10, p.y / 10, r / 10);
+    // draw a line, adjusted for compression
+    // canvas_draw_line(
+    //     canvas,
+    //     roundf(p.x),
+    //     roundf(p.y),
+    //     roundf(p2.x),
+    //     //roundf(me->p.y - (plunger->size - plunger->compression))
+    //     roundf(p2.y));
+}
+
+void Chaser::draw(Canvas* canvas) {
+    Vec2& p1 = points[0];
+    Vec2& p2 = points[1];
+
+    // TODO: feels like we can do all this with less code?
+    switch(style) {
+    case Style::SLASH: // / / / / / / / / /
+        if(p1.x == p2.x) {
+            int start = p1.y;
+            int end = p2.y;
+            if(start < end) {
+                for(int y = start + offset; y < end; y += gap) {
+                    canvas_draw_line(canvas, p1.x - 2, y + 2, p1.x + 2, y - 2);
+                }
+            } else {
+                for(int y = start - offset; y > end; y -= gap) {
+                    canvas_draw_line(canvas, p1.x - 2, y + 2, p1.x + 2, y - 2);
+                }
+            }
+        } else if(p1.y == p2.y) {
+            int start = p1.x;
+            int end = p2.x;
+            if(start < end) {
+                for(int x = start + offset; x < end; x += gap) {
+                    canvas_draw_line(canvas, x - 2, p1.y + 2, x + 2, p1.y - 2);
+                }
+            } else {
+                for(int x = start - offset; x > end; x -= gap) {
+                    canvas_draw_line(canvas, x - 2, p1.y + 2, x + 2, p1.y - 2);
+                }
+            }
+        }
+        break;
+    default: // Style::SIMPLE, just dots
+        // for all pixels between p and q, draw them with offset and gap
+        if(p1.x == p2.x) {
+            int start = p1.y;
+            int end = p2.y;
+            if(start < end) {
+                for(int y = start + offset; y < end; y += gap) {
+                    canvas_draw_disc(canvas, p1.x, y, 1);
+                }
+            } else {
+                for(int y = start - offset; y > end; y -= gap) {
+                    canvas_draw_disc(canvas, p1.x, y, 1);
+                }
+            }
+        } else if(p1.y == p2.y) {
+            int start = p1.x;
+            int end = p2.x;
+            if(start < end) {
+                for(int x = start + offset; x < end; x += gap) {
+                    canvas_draw_disc(canvas, x, p1.y, 1);
+                }
+            } else {
+                for(int x = start - offset; x > end; x -= gap) {
+                    canvas_draw_disc(canvas, x, p1.y, 1);
+                }
+            }
+        }
+        break;
+    }
+}
+
+void Chaser::step_animation() {
+    tick++;
+    if(tick % (speed) == 0) {
+        offset = (offset + 1) % gap;
+    }
+}

+ 285 - 0
objects.h

@@ -0,0 +1,285 @@
+#pragma once
+#include <vector>
+#include "vec2.h"
+#include <gui/canvas.h> // for Canvas*
+
+#define DEF_BALL_RADIUS   20
+#define DEF_BUMPER_RADIUS 40
+#define DEF_BUMPER_BOUNCE 1.0f
+#define DEF_FLIPPER_SIZE  120
+#define DEF_RAIL_BOUNCE   0.9f
+
+#define ARC_TANGENT_RESTITUTION 1.0f
+#define ARC_NORMAL_RESTITUTION  0.8f
+
+// A dynamic, moveable object with acceleration
+class Object {
+public:
+    Object(const Vec2& p_, float r_);
+    virtual ~Object() = default;
+
+    // Verlet data
+    Vec2 p; // position
+    Vec2 prev_p; // previous position
+    Vec2 a;
+    float r;
+
+    bool physical; // is this a real object that can be hit?
+    float bounce; // < 1 dampens, > 1 adds power
+    bool fixed; // should this move?
+    int score; // incremental score for hitting this
+
+    void update(float dt); // updates position
+    inline void accelerate(const Vec2& da) {
+        a += da;
+    }
+    inline void add_velocity(const Vec2& v, float dt) {
+        prev_p -= v * dt;
+    }
+
+    virtual void draw(Canvas* canvas) = 0;
+};
+
+class Ball : public Object {
+public:
+    Ball(const Vec2& p_, float r_ = DEF_BALL_RADIUS)
+        : Object(p_, r_) {
+    }
+    void draw(Canvas* canvas);
+};
+
+class Flipper {
+public:
+    enum Side {
+        LEFT,
+        RIGHT
+    };
+
+    Flipper(const Vec2& p_, Side side, size_t size_ = DEF_FLIPPER_SIZE);
+
+    void draw(Canvas* canvas);
+    void update(float dt); // updates position to new position
+    bool collide(Ball& ball);
+
+    Vec2 get_tip() const;
+
+    Vec2 p;
+    Side side;
+    size_t size;
+    float r;
+
+    float rest_angle;
+    float max_rotation;
+    float sign;
+    float omega; // angular velocity
+
+    float rotation;
+    float current_omega;
+
+    bool powered; // is this flipper being activated? i.e. is keypad pressed?
+};
+
+// A static object that never moves and can be any shape
+class FixedObject {
+public:
+    FixedObject()
+        : bounce(1.0f)
+        , physical(true)
+        , hidden(false) {
+    }
+    virtual ~FixedObject() = default;
+
+    float bounce;
+    bool physical; // can be hit
+    bool hidden; // do not draw
+
+    virtual void draw(Canvas* canvas) = 0;
+    virtual bool collide(Ball& ball) = 0;
+    virtual void reset_animation() {};
+    virtual void step_animation() {};
+};
+
+class Polygon : public FixedObject {
+public:
+    Polygon()
+        : FixedObject() {};
+
+    std::vector<Vec2> points;
+    std::vector<Vec2> normals;
+
+    void draw(Canvas* canvas);
+    bool collide(Ball& ball);
+    void add_point(const Vec2& np) {
+        points.push_back(np);
+    }
+    void finalize();
+};
+
+class Portal : public FixedObject {
+public:
+    Portal(const Vec2& a1_, const Vec2& a2_, const Vec2& b1_, const Vec2& b2_)
+        : FixedObject()
+        , a1(a1_)
+        , a2(a2_)
+        , b1(b1_)
+        , b2(b2_) {
+    }
+    Vec2 a1, a2; // portal 'a'
+    Vec2 b1, b2; // portal 'b'
+    Vec2 na, nb; // normals
+    Vec2 au, bu; // unit vectors
+    float amag, bmag; // length of portals
+    bool bidirectional{true}; // TODO: ehhh?
+
+    Vec2 enter_p; // where we entered portal
+    size_t decay{0}; // used for animation
+
+    void draw(Canvas* canvas);
+    bool collide(Ball& ball);
+    void reset_animation();
+    void step_animation();
+    void finalize();
+};
+
+class Arc : public FixedObject {
+public:
+    enum Surface {
+        OUTSIDE,
+        INSIDE,
+        BOTH
+    };
+
+    Arc(const Vec2& p_,
+        float r_,
+        float s_ = 0,
+        float e_ = (float)M_PI * 2,
+        Surface surf_ = OUTSIDE);
+
+    Vec2 p;
+    float r;
+    float start;
+    float end;
+    Surface surface;
+    void draw(Canvas* canvas);
+    bool collide(Ball& ball);
+};
+
+class Bumper : public Arc {
+public:
+    Bumper(const Vec2& p_, float r_);
+
+    size_t decay;
+
+    void draw(Canvas* canvas);
+    void reset_animation();
+    void step_animation();
+};
+
+class Plunger : public Object {
+public:
+    Plunger(const Vec2& p_);
+
+    void draw(Canvas* canvas);
+
+    int size; // how tall is it
+    int compression; // how much is it pulled back?
+};
+
+// Simply displays a letter after a rollover
+class Rollover : public FixedObject {
+public:
+    Rollover(const Vec2& p_, char c_)
+        : FixedObject()
+        , p(p_) {
+        c[0] = c_;
+        c[1] = '\0';
+    }
+
+    Vec2 p;
+    char c[2];
+    bool activated{false};
+
+    void draw(Canvas* canvas);
+    bool collide(Ball& ball);
+};
+
+class Turbo : public FixedObject {
+public:
+    Turbo(const Vec2& p_, float angle_, float boost_)
+        : FixedObject()
+        , p(p_)
+        , angle(angle_)
+        , boost(boost_) {
+        dir = Vec2(cosf(angle), -sinf(angle));
+
+        // for now, fix the radius to 30 or whatever
+        size_t r = 30;
+        chevron_1[0] = Vec2(p.x, p.y - r);
+        chevron_1[1] = Vec2(p.x + r, p.y);
+        chevron_1[2] = Vec2(p.x, p.y + r);
+
+        chevron_2[0] = Vec2(p.x - r, p.y - r);
+        chevron_2[1] = Vec2(p.x, p.y);
+        chevron_2[2] = Vec2(p.x - r, p.y + r);
+
+        for(size_t i = 0; i < 3; i++) {
+            Vec2& v = chevron_1[i];
+            Vec2 d = v - p;
+            v.x = p.x + d.x * cosf(angle) - d.y * sinf(angle);
+            v.y = p.y + d.x * -sinf(angle) + d.y * -cosf(angle);
+        }
+        for(size_t i = 0; i < 3; i++) {
+            Vec2& v = chevron_2[i];
+            Vec2 d = v - p;
+            v.x = p.x + d.x * cosf(angle) - d.y * sinf(angle);
+            v.y = p.y + d.x * -sinf(angle) + d.y * -cosf(angle);
+        }
+    }
+
+    Vec2 p;
+    float angle;
+    float boost;
+
+    Vec2 dir; // unit normal of turbo direction
+
+    Vec2 chevron_1[3];
+    Vec2 chevron_2[3];
+
+    void draw(Canvas* canvas);
+    bool collide(Ball& ball);
+};
+
+// Visual item only - chase of dots in one direction
+// AXIS-ALIGNED!
+class Chaser : public Polygon {
+public:
+    enum Style {
+        SIMPLE,
+        SLASH
+    };
+
+    Chaser(const Vec2& p1, const Vec2& p2, size_t gap_ = 8, size_t speed_ = 3, Style style_ = SIMPLE)
+        : Polygon()
+        , tick(0)
+        , offset(0)
+        , gap(gap_)
+        , speed(speed_)
+        , style(style_) {
+        physical = false;
+        points.push_back(p1);
+        points.push_back(p2);
+    }
+
+    size_t tick;
+    size_t offset;
+    size_t gap;
+    size_t speed;
+    Style style;
+
+    void draw(Canvas* canvas);
+    void step_animation();
+};
+
+// class IconImage : public Object {
+//     Vec2 v;
+// };

+ 469 - 0
pinball0.cxx

@@ -0,0 +1,469 @@
+#include <furi.h>
+#include <cstring>
+#include "pinball0.h"
+
+/* generated by fbt from .png files in images folder */
+#include <pinball0_icons.h>
+
+// Gravity should be lower than 9.8 m/s^2 since the ball is on
+// an angled table. We could calc this and derive the actual
+// vertical vector based on the angle of the table yadda yadda yadda
+#define GRAVITY           3.0f // 9.8f
+#define PHYSICS_SUB_STEPS 5
+#define GAME_FPS          30
+#define TABLE_BUMP_AMOUNT 0.3l
+
+#define MANUAL_MODE       true
+#define MANUAL_ADJUSTMENT 20
+
+// Sound definitions
+// static const NotificationSequence ns_short_sound = {
+//     &message_note_c5,
+//     &message_delay_50,
+//     &message_sound_off,
+//     NULL,
+// };
+
+void solve(PinballState* pb, float dt) {
+    Table* table = pb->table;
+
+    float sub_dt = dt / PHYSICS_SUB_STEPS;
+    for(int ss = 0; ss < PHYSICS_SUB_STEPS; ss++) {
+        // apply gravity (and any other forces?)
+        // FURI_LOG_I(TAG, "Applying gravity");
+        if(table->balls_released) {
+            float bump_amt = 1.0f;
+            if(pb->keys[InputKeyUp]) {
+                bump_amt = -1.04f;
+            }
+            for(auto& b : table->balls) {
+                // We multiply GRAVITY by dt since gravity is based on seconds
+                b.accelerate(Vec2(0, GRAVITY * bump_amt * sub_dt));
+            }
+        }
+
+        // apply collisions (among moving objects)
+        // only needed for multi-ball! - is this true? what about flippers...
+        for(size_t b1 = 0; b1 < table->balls.size(); b1++) {
+            for(size_t b2 = b1 + 1; b2 < table->balls.size(); b2++) {
+                if(b1 != b2) {
+                    auto& ball1 = table->balls[b1];
+                    auto& ball2 = table->balls[b2];
+
+                    Vec2 axis = ball1.p - ball2.p;
+                    float dist2 = axis.mag2();
+                    float dist = sqrtf(dist2);
+                    float rr = ball1.r + ball2.r;
+                    if(dist < rr) {
+                        Vec2 v1 = ball1.p - ball1.prev_p;
+                        Vec2 v2 = ball2.p - ball2.prev_p;
+
+                        float factor = (dist - rr) / dist;
+                        ball1.p -= axis * factor * 0.5f;
+                        ball2.p -= axis * factor * 0.5f;
+
+                        float damping = 1.01f;
+                        float f1 = (damping * (axis.x * v1.x + axis.y * v1.y)) / dist2;
+                        float f2 = (damping * (axis.x * v2.x + axis.y * v2.y)) / dist2;
+
+                        v1.x += f2 * axis.x - f1 * axis.x;
+                        v2.x += f1 * axis.x - f2 * axis.x;
+                        v1.y += f2 * axis.y - f1 * axis.y;
+                        v2.y += f1 * axis.y - f2 * axis.y;
+
+                        ball1.prev_p = ball1.p - v1;
+                        ball2.prev_p = ball2.p - v2;
+                    }
+                }
+            }
+        }
+
+        // collisions with static objects and flippers
+        for(auto& b : table->balls) {
+            for(auto& o : table->objects) {
+                if(o->physical && o->collide(b)) {
+                    o->reset_animation();
+                    continue;
+                }
+            }
+            for(auto& f : table->flippers) {
+                if(f.collide(b)) {
+                    continue;
+                }
+            }
+        }
+
+        // update positions - of balls AND flippers
+        if(table->balls_released) {
+            for(auto& b : table->balls) {
+                b.update(sub_dt);
+            }
+        }
+        for(auto& f : table->flippers) {
+            f.update(sub_dt);
+        }
+    }
+
+    // Did any balls fall off the table?
+    size_t num_in_play = table->balls.size();
+    auto i = table->balls.begin();
+    while(i != table->balls.end()) {
+        if(i->p.y > 1280 + 100) {
+            FURI_LOG_I(TAG, "ball off table!");
+            i = table->balls.erase(i);
+            num_in_play--;
+        } else {
+            ++i;
+        }
+    }
+    if(num_in_play == 0) {
+        table->balls_released = false;
+        table->num_lives--;
+        if(table->num_lives > 0) {
+            table->balls = table->balls_initial;
+        }
+    }
+}
+
+void pinball_state_init(PinballState* pb) {
+    furi_assert(pb);
+    pb->storage = (Storage*)furi_record_open(RECORD_STORAGE);
+    pb->notify = (NotificationApp*)furi_record_open(RECORD_NOTIFICATION);
+
+    pb->table = NULL;
+    pb->tick = 0;
+    pb->gameStarted = false;
+
+    pb->game_mode = GM_TableSelect;
+    pb->keys[InputKeyUp] = false;
+    pb->keys[InputKeyDown] = false;
+    pb->keys[InputKeyRight] = false;
+    pb->keys[InputKeyLeft] = false;
+}
+
+int modulo(int a, int b) {
+    return (a % b + b) % b;
+}
+
+static void pinball_draw_callback(Canvas* const canvas, void* ctx) {
+    furi_assert(ctx);
+    PinballState* pb = (PinballState*)ctx;
+    furi_mutex_acquire(pb->mutex, FuriWaitForever);
+
+    // What are we drawing? table select / menu or the actual game?
+    switch(pb->game_mode) {
+    case GM_TableSelect: {
+        canvas_draw_icon(canvas, 0, 0, &I_pinball0_logo); // our sweet logo
+        // draw the list of table names: display it as a carousel - where the list repeats
+        // and the currently selected item is always in the middle, surrounded by pinballs
+        const TableList& list = pb->table_list;
+        int32_t y = 25;
+        size_t half_way = list.display_size / 2;
+
+        for(size_t i = 0; i < list.display_size; i++) {
+            int index =
+                (list.selected - half_way + i + list.menu_items.size()) % list.menu_items.size();
+            const auto& menu_item = list.menu_items[index];
+            canvas_draw_str_aligned(
+                canvas,
+                LCD_WIDTH / 2,
+                y,
+                AlignCenter,
+                AlignTop,
+                furi_string_get_cstr(menu_item.name));
+            if(i == half_way) {
+                canvas_draw_disc(canvas, 8, y + 3, 2);
+                canvas_draw_disc(canvas, 56, y + 3, 2);
+            }
+            y += 12;
+        }
+
+        pb->table->draw(canvas);
+    } break;
+    case GM_Playing:
+        pb->table->draw(canvas);
+        break;
+    case GM_GameOver: {
+        // pb->table->draw(canvas);
+        int32_t y = 56;
+        size_t interval = 40;
+        float theta = (float)((pb->tick % interval) / (interval * 1.0f)) * (float)(M_PI * 2);
+        FURI_LOG_I(TAG, "tick: %lu, theta: %.4f", pb->tick, (double)theta);
+        // float theta_offset = (float)((pb->tick % 16) / 16.0);
+        float theta_offset = 0;
+        // int32_t y_offset = y + sinf(theta) * 4;
+        canvas_draw_icon(canvas, 16, y + sinf(theta) * 4, &I_Arcade_G);
+        canvas_draw_icon(canvas, 24, y + sinf(theta + theta_offset) * 4, &I_Arcade_A);
+        canvas_draw_icon(canvas, 32, y + sinf(theta + theta_offset * 2) * 4, &I_Arcade_M);
+        canvas_draw_icon(canvas, 40, y + sinf(theta + theta_offset * 3) * 4, &I_Arcade_E);
+
+        canvas_draw_icon(canvas, 16, y + sinf(theta) * 4 + 8, &I_Arcade_O);
+        canvas_draw_icon(canvas, 24, y + sinf(theta + theta_offset) * 4 + 8, &I_Arcade_V);
+        canvas_draw_icon(canvas, 32, y + sinf(theta + theta_offset * 2) * 4 + 8, &I_Arcade_E);
+        canvas_draw_icon(canvas, 40, y + sinf(theta + theta_offset * 3) * 4 + 8, &I_Arcade_R);
+    } break;
+    case GM_Error: {
+        // pb->text contains error message
+        canvas_draw_icon(canvas, 0, 10, &I_Arcade_E);
+        canvas_draw_icon(canvas, 8, 10, &I_Arcade_R);
+        canvas_draw_icon(canvas, 16, 10, &I_Arcade_R);
+        canvas_draw_icon(canvas, 24, 10, &I_Arcade_O);
+        canvas_draw_icon(canvas, 32, 10, &I_Arcade_R);
+
+        int x = 10;
+        int y = 30;
+        // split the string on \n and display each line
+        // strtok is disabled - whyyy
+        char buf[256];
+        strncpy(buf, pb->text, 256);
+        char* str = buf;
+        char* p = buf;
+        bool at_end = false;
+        while(str != NULL) {
+            while(p && *p != '\n' && *p != '\0')
+                p++;
+            if(p && *p == '\0') at_end = true;
+            *p = '\0';
+            canvas_draw_str_aligned(canvas, x, y, AlignLeft, AlignTop, str);
+            if(at_end) {
+                str = NULL;
+                break;
+            }
+            str = p + 1;
+            p = str;
+            y += 12;
+        }
+
+        pb->table->draw(canvas);
+    } break;
+    default:
+        FURI_LOG_E(TAG, "Unknown Game Mode");
+        break;
+    }
+
+    furi_mutex_release(pb->mutex);
+}
+
+static void pinball_input_callback(InputEvent* input_event, void* ctx) {
+    furi_assert(ctx);
+    FuriMessageQueue* event_queue = (FuriMessageQueue*)ctx;
+    PinballEvent event = {.type = EventTypeKey, .input = *input_event};
+    furi_message_queue_put(event_queue, &event, FuriWaitForever);
+}
+
+extern "C" int32_t pinball0_app(void* p) {
+    UNUSED(p);
+
+    PinballState* pinball_state = (PinballState*)malloc(sizeof(PinballState));
+    pinball_state->mutex = furi_mutex_alloc(FuriMutexTypeNormal);
+    if(!pinball_state->mutex) {
+        FURI_LOG_E(TAG, "Cannot create mutex!");
+        free(pinball_state);
+        return 0;
+    }
+
+    pinball_state_init(pinball_state);
+
+    // read the list of tables from storage
+    table_table_list_init(pinball_state);
+
+    // load the table select table
+    table_load_table(pinball_state, 0);
+
+    FuriMessageQueue* event_queue = furi_message_queue_alloc(8, sizeof(PinballEvent));
+    furi_timer_set_thread_priority(FuriTimerThreadPriorityElevated);
+
+    ViewPort* view_port = view_port_alloc();
+    view_port_set_orientation(view_port, ViewPortOrientationVertical);
+    view_port_draw_callback_set(view_port, pinball_draw_callback, pinball_state);
+    view_port_input_callback_set(view_port, pinball_input_callback, event_queue);
+
+    // Open the GUI and register view_port
+    Gui* gui = (Gui*)furi_record_open(RECORD_GUI);
+    gui_add_view_port(gui, view_port, GuiLayerFullscreen);
+
+    notification_message(pinball_state->notify, &sequence_display_backlight_enforce_on);
+
+    // dolphin_deed(DolphinDeedPluginGameStart);
+
+    pinball_state->processing = true;
+
+    float dt = 0.0f;
+    uint32_t last_frame_time = furi_get_tick();
+
+    FURI_LOG_I(TAG, "Starting event loop");
+    PinballEvent event;
+    while(pinball_state->processing) {
+        FuriStatus event_status =
+            furi_message_queue_get(event_queue, &event, 10); // TODO best rate?
+        furi_mutex_acquire(pinball_state->mutex, FuriWaitForever);
+
+        if(event_status == FuriStatusOk) {
+            if(event.type == EventTypeKey) {
+                if(event.input.type == InputTypePress || event.input.type == InputTypeLong ||
+                   event.input.type == InputTypeRepeat) {
+                    switch(event.input.key) {
+                    case InputKeyBack:
+                        if(pinball_state->game_mode == GM_Playing ||
+                           pinball_state->game_mode == GM_GameOver ||
+                           pinball_state->game_mode == GM_Error) {
+                            pinball_state->game_mode = GM_TableSelect;
+                            table_load_table(pinball_state, TABLE_SELECT);
+                        } else if(pinball_state->game_mode == GM_TableSelect) {
+                            pinball_state->processing = false;
+                        }
+                        break;
+                    case InputKeyRight:
+                        pinball_state->keys[InputKeyRight] = true;
+                        // temp
+                        if(MANUAL_MODE && pinball_state->table->balls_released == false) {
+                            pinball_state->table->balls[0].p.x += MANUAL_ADJUSTMENT;
+                            pinball_state->table->balls[0].prev_p.x += MANUAL_ADJUSTMENT;
+                        }
+                        for(auto& f : pinball_state->table->flippers) {
+                            if(f.side == Flipper::RIGHT) {
+                                f.powered = true;
+                            }
+                        }
+                        break;
+                    case InputKeyLeft:
+                        // FURI_LOG_I(TAG, "LEFT on");
+                        pinball_state->keys[InputKeyLeft] = true;
+                        // temp
+                        if(MANUAL_MODE && pinball_state->table->balls_released == false) {
+                            pinball_state->table->balls[0].p.x -= MANUAL_ADJUSTMENT;
+                            pinball_state->table->balls[0].prev_p.x -= MANUAL_ADJUSTMENT;
+                        }
+                        for(auto& f : pinball_state->table->flippers) {
+                            if(f.side == Flipper::LEFT) {
+                                f.powered = true;
+                            }
+                        }
+                        break;
+                    case InputKeyUp: // bump table
+                        if(pinball_state->game_mode == GM_Playing) {
+                            if(event.input.type == InputTypePress) {
+                                pinball_state->keys[InputKeyUp] = true;
+                            }
+                        } else if(pinball_state->game_mode == GM_TableSelect) {
+                            pinball_state->table_list.selected =
+                                (pinball_state->table_list.selected - 1 +
+                                 pinball_state->table_list.menu_items.size()) %
+                                pinball_state->table_list.menu_items.size();
+                        }
+                        // temp
+                        if(MANUAL_MODE && pinball_state->table->balls_released == false) {
+                            pinball_state->table->balls[0].p.y -= MANUAL_ADJUSTMENT;
+                            pinball_state->table->balls[0].prev_p.y -= MANUAL_ADJUSTMENT;
+                        }
+                        break;
+                    case InputKeyDown:
+                        if(pinball_state->game_mode == GM_Playing) {
+                            pinball_state->keys[InputKeyDown] = true;
+                        } else if(pinball_state->game_mode == GM_TableSelect) {
+                            pinball_state->table_list.selected =
+                                (pinball_state->table_list.selected + 1) %
+                                pinball_state->table_list.menu_items.size();
+                        }
+                        // temp
+                        if(MANUAL_MODE && pinball_state->table->balls_released == false) {
+                            pinball_state->table->balls[0].p.y += MANUAL_ADJUSTMENT;
+                            pinball_state->table->balls[0].prev_p.y += MANUAL_ADJUSTMENT;
+                        }
+                        break;
+                    case InputKeyOk:
+                        if(pinball_state->game_mode == GM_Playing) {
+                            if(!pinball_state->table->balls_released) {
+                                pinball_state->gameStarted = true;
+                                pinball_state->table->balls_released = true;
+                            }
+                        } else if(pinball_state->game_mode == GM_TableSelect) {
+                            size_t sel = pinball_state->table_list.selected;
+                            if(!table_load_table(pinball_state, sel + TABLE_INDEX_OFFSET)) {
+                                pinball_state->game_mode = GM_Error;
+                                table_load_table(pinball_state, TABLE_ERROR);
+                            } else {
+                                pinball_state->game_mode = GM_Playing;
+                            }
+                        }
+                        break;
+                    default:
+                        break;
+                    }
+                } else if(event.input.type == InputTypeRelease) {
+                    switch(event.input.key) {
+                    case InputKeyLeft: {
+                        pinball_state->keys[InputKeyLeft] = false;
+                        for(auto& f : pinball_state->table->flippers) {
+                            if(f.side == Flipper::LEFT) {
+                                f.powered = false;
+                            }
+                        }
+                        break;
+                    }
+                    case InputKeyRight: {
+                        pinball_state->keys[InputKeyRight] = false;
+                        for(auto& f : pinball_state->table->flippers) {
+                            if(f.side == Flipper::RIGHT) {
+                                f.powered = false;
+                            }
+                        }
+                        break;
+                    }
+                    case InputKeyUp:
+                        pinball_state->keys[InputKeyUp] = false;
+                        break;
+                    case InputKeyDown:
+                        pinball_state->keys[InputKeyDown] = false;
+                        // TODO: release plunger?
+                        break;
+                    default:
+                        break;
+                    }
+                }
+            }
+        }
+        solve(pinball_state, dt);
+        for(auto& o : pinball_state->table->objects) {
+            o->step_animation();
+        }
+        // check game state
+        if(pinball_state->game_mode == GM_Playing && pinball_state->table->num_lives == 0) {
+            FURI_LOG_W(TAG, "GAME OVER!");
+            pinball_state->game_mode = GM_GameOver;
+        }
+
+        // no keys pressed - we should clear all input keys?
+        view_port_update(view_port);
+        furi_mutex_release(pinball_state->mutex);
+
+        // game timing
+        uint32_t time_lapsed = furi_get_tick() - last_frame_time;
+        dt = time_lapsed / 1000.0f;
+        while(dt < 1.0f / GAME_FPS) {
+            time_lapsed = furi_get_tick() - last_frame_time;
+            dt = time_lapsed / 1000.0f;
+        }
+        pinball_state->tick++;
+        last_frame_time = furi_get_tick();
+    }
+
+    // general cleanup
+    notification_message(pinball_state->notify, &sequence_display_backlight_enforce_auto);
+
+    view_port_enabled_set(view_port, false);
+    gui_remove_view_port(gui, view_port);
+    furi_record_close(RECORD_GUI);
+    furi_record_close(RECORD_STORAGE);
+    furi_record_close(RECORD_NOTIFICATION);
+    view_port_free(view_port);
+    furi_message_queue_free(event_queue);
+
+    furi_mutex_free(pinball_state->mutex);
+
+    delete pinball_state->table;
+    free(pinball_state);
+
+    furi_timer_set_thread_priority(FuriTimerThreadPriorityNormal);
+    return 0;
+}

+ 64 - 0
pinball0.h

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <gui/gui.h>
+// #include <input/input.h>
+#include <stdlib.h>
+#include <stdbool.h>
+#include <string.h>
+#include <cmath>
+// #include <furi_hal_resources.h>
+// #include <furi_hal_gpio.h>
+#include <dolphin/dolphin.h>
+#include <storage/storage.h>
+#include <notification/notification.h>
+#include <notification/notification_messages.h>
+
+#include "vec2.h"
+#include "objects.h"
+#include "table.h"
+
+// #define DRAW_NORMALS
+
+#define TAG "Pinball0"
+
+// Vertical orientation
+#define LCD_WIDTH  64
+#define LCD_HEIGHT 128
+
+typedef enum {
+    EventTypeTick,
+    EventTypeKey
+} EventType;
+
+typedef struct {
+    EventType type;
+    InputEvent input;
+} PinballEvent;
+
+typedef enum GameMode {
+    GM_TableSelect,
+    GM_Playing,
+    GM_GameOver,
+    GM_Error,
+    GM_About // TODO
+} GameMode;
+
+typedef struct {
+    FuriMutex* mutex;
+
+    TableList table_list;
+
+    GameMode game_mode;
+    Table* table; // data for the current table
+    uint32_t tick;
+
+    bool gameStarted;
+    bool keys[4]; // which key was pressed?
+    bool processing; // controls game loop and physics threads
+
+    // system objects
+    Storage* storage;
+    NotificationApp* notify; // allows us to blink/buzz during game
+    char text[256]; // general temp buffer
+
+} PinballState;


BIN
screenshots/screenshot_basic.png


BIN
screenshots/screenshot_chamber.png


BIN
screenshots/screenshot_el_ocho.png


BIN
screenshots/screenshot_menu.png


+ 625 - 0
table.cxx

@@ -0,0 +1,625 @@
+#include <toolbox/dir_walk.h>
+#include <toolbox/path.h>
+#include <toolbox/stream/stream.h>
+#include <toolbox/stream/file_stream.h>
+#include <toolbox/args.h>
+
+#include "nxjson/nxjson.h"
+#include "pinball0.h"
+#include "table.h"
+
+// Table defaults
+#define LIVES     3
+#define LIVES_POS Vec2(20, 20)
+
+// forward declares
+Table* table_init_table_select(void* ctx);
+Table* table_init_table_error(void* ctx);
+
+Table::~Table() {
+    for(size_t i = 0; i < objects.size(); i++) {
+        delete objects[i];
+    }
+    if(plunger != nullptr) {
+        delete plunger;
+    }
+}
+
+void Table::draw(Canvas* canvas) {
+    // da balls
+    for(auto& b : balls) {
+        b.draw(canvas);
+    }
+
+    // loop through objects on the table and draw them
+    for(auto& o : objects) {
+        o->draw(canvas);
+    }
+
+    for(auto& f : flippers) {
+        f.draw(canvas);
+    }
+
+    if(plunger) {
+        plunger->draw(canvas);
+    }
+
+    // we don't draw the last one, as it's in play!
+    if(num_lives > 0) {
+        float x = num_lives_pos.x;
+        float y = num_lives_pos.y;
+        for(size_t l = 0; l < num_lives - 1; x += (2 * 20) + 20, l++) {
+            canvas_draw_disc(canvas, x / 10, y / 10, 2);
+        }
+    }
+
+    // score
+    // char buf[32];
+    // snprintf(buf, 32, "%8u", score);
+    // canvas_draw_str_aligned(canvas, LCD_WIDTH - 30, 1, AlignLeft, AlignTop, buf);
+}
+
+void table_table_list_init(void* ctx) {
+    PinballState* pb = (PinballState*)ctx;
+    // using the asset file path, read the table files, and for each one, extract their
+    // display name (oof). let's just use their filenames for now (stripping any XX_ prefix)
+    // sort tables by original filename
+
+    const char* paths[] = {APP_ASSETS_PATH("tables"), APP_DATA_PATH("tables")};
+
+    for(size_t p = 0; p < 2; p++) {
+        const char* path = paths[p];
+        // const char* asset_path = APP_ASSETS_PATH("tables");
+        FURI_LOG_I(TAG, "Loading table list from: %s", path);
+
+        FuriString* table_path = furi_string_alloc();
+
+        DirWalk* dir_walk = dir_walk_alloc(pb->storage);
+        dir_walk_set_recursive(dir_walk, false);
+        if(dir_walk_open(dir_walk, path)) {
+            while(dir_walk_read(dir_walk, table_path, NULL) == DirWalkOK) {
+                FURI_LOG_I(TAG, furi_string_get_cstr(table_path));
+                // set display 'name' and 'filename'
+                TableMenuItem tmi;
+                const char* cpath = furi_string_get_cstr(table_path);
+                tmi.filename = furi_string_alloc_set_str(cpath);
+
+                tmi.name = furi_string_alloc();
+                path_extract_filename_no_ext(cpath, tmi.name);
+
+                // If filename starts with XX_ (for custom sorting) strip the prefix
+                char c = furi_string_get_char(tmi.name, 2);
+                if(c == '_') {
+                    char a = furi_string_get_char(tmi.name, 0);
+                    char b = furi_string_get_char(tmi.name, 1);
+                    if(a >= '0' && a <= '9' && b >= '0' && b <= '9') {
+                        furi_string_right(tmi.name, 3);
+                    }
+                }
+
+                // Insert in sorted order
+                size_t i = 0;
+                auto it = pb->table_list.menu_items.begin();
+                for(; it != pb->table_list.menu_items.end(); it++, i++) {
+                    if(strcmp(
+                           furi_string_get_cstr(tmi.filename),
+                           furi_string_get_cstr(it->filename)) > 0) {
+                        continue;
+                    }
+                    pb->table_list.menu_items.insert(it, tmi);
+                    break;
+                }
+                if(pb->table_list.menu_items.size() == i) {
+                    pb->table_list.menu_items.push_back(tmi);
+                }
+            }
+        }
+        furi_string_free(table_path);
+        dir_walk_free(dir_walk);
+    }
+
+    FURI_LOG_I(TAG, "Found %d tables", pb->table_list.menu_items.size());
+    for(auto& tmi : pb->table_list.menu_items) {
+        FURI_LOG_I(TAG, "%s", furi_string_get_cstr(tmi.name));
+    }
+    pb->table_list.display_size = 5; // how many tables to display at once
+    pb->table_list.selected = 0;
+}
+
+// json parse helper function
+bool table_file_parse_vec2(const nx_json* json, const char* key, Vec2* v) {
+    furi_assert(v);
+    const nx_json* item = nx_json_get(json, key);
+    if(!item || item->children.length != 2) {
+        return false;
+    }
+    v->x = nx_json_item(item, 0)->num.dbl_value;
+    v->y = nx_json_item(item, 1)->num.dbl_value;
+    return true;
+}
+
+bool table_file_parse_int(const nx_json* json, const char* key, int* v) {
+    furi_assert(v);
+    const nx_json* item = nx_json_get(json, key);
+    if(!item) return false;
+    *v = item->num.u_value;
+    return true;
+}
+
+bool table_file_parse_float(const nx_json* json, const char* key, float* v) {
+    furi_assert(v);
+    const nx_json* item = nx_json_get(json, key);
+    if(!item) return false;
+    *v = item->num.dbl_value;
+    return true;
+}
+
+Table* table_load_table_from_file(PinballState* pb, size_t index) {
+    auto& tmi = pb->table_list.menu_items[index];
+
+    FURI_LOG_I(TAG, "Reading file: %s", furi_string_get_cstr(tmi.filename));
+
+    File* file = storage_file_alloc(pb->storage);
+    FileInfo fileinfo;
+    FS_Error error =
+        storage_common_stat(pb->storage, furi_string_get_cstr(tmi.filename), &fileinfo);
+    if(error != FSE_OK) {
+        FURI_LOG_E(TAG, "Could not find file");
+        storage_file_free(file);
+        return NULL;
+    }
+    // TODO: determine an appropriate max file size and make configurable
+    FURI_LOG_I(TAG, "Found file ok!");
+    if(fileinfo.size >= 8192) {
+        FURI_LOG_E(TAG, "Table file size too big");
+        snprintf(pb->text, 256, "Table file\nis too big!\n> 8192 bytes");
+        storage_file_free(file);
+        return NULL;
+    }
+    FURI_LOG_I(TAG, "File size is ok!");
+    bool ok =
+        storage_file_open(file, furi_string_get_cstr(tmi.filename), FSAM_READ, FSOM_OPEN_EXISTING);
+    FURI_LOG_I(TAG, "File opened? %s", ok ? "YES" : "NO");
+
+    // read the file as a string
+    uint8_t* buffer;
+    uint64_t file_size = storage_file_size(file);
+    if(file_size > 8192) { // TODO - what's the right size?
+        FURI_LOG_E(TAG, "Table file is too large! (> 8192 bytes)");
+        snprintf(pb->text, 256, "Table file\nis too big!\n> 8192 bytes");
+        storage_file_free(file);
+        return NULL;
+    }
+    buffer = (uint8_t*)malloc(file_size);
+    size_t read_count = storage_file_read(file, buffer, file_size);
+    // if(storage_file_get_error(file) != FSE_OK) {
+    //     FURI_LOG_E(TAG, "Um, couldn't read file");
+    //     storage_file_free(file);
+    //     return NULL;
+    // }
+    storage_file_free(file);
+
+    if(read_count != file_size) {
+        FURI_LOG_E(TAG, "Error reading file. expected %lld, got %d", file_size, read_count);
+        free(buffer);
+        return NULL;
+    }
+    FURI_LOG_I(TAG, "Read file into buffer! %d bytes", read_count);
+
+    // let's parse this shit
+    char* json_buffer = (char*)malloc(read_count * sizeof(char) + 1);
+    for(uint16_t i = 0; i < read_count; i++) {
+        json_buffer[i] = buffer[i];
+    }
+    json_buffer[read_count] = 0;
+    free(buffer);
+
+    const nx_json* json = nx_json_parse(json_buffer, 0);
+
+    if(!json) {
+        FURI_LOG_E(TAG, "Failed to parse table json!");
+        snprintf(pb->text, 256, "Failed to\nparse table\njson!!");
+        free(json_buffer);
+        return NULL;
+    }
+
+    Table* table = new Table();
+
+    int lives = LIVES;
+    table_file_parse_int(json, "lives", &lives);
+    table->num_lives = lives;
+
+    table->num_lives_pos = LIVES_POS;
+    table_file_parse_vec2(json, "lives_position", &table->num_lives_pos);
+
+    // TODO: this should be an attribute of balls?
+    table->balls_released = false;
+    const nx_json* released = nx_json_get(json, "released");
+    if(released) {
+        table->balls_released = released->num.u_value > 0;
+    }
+
+    do {
+        const nx_json* balls = nx_json_get(json, "balls");
+        if(balls) {
+            for(int i = 0; i < balls->children.length; i++) {
+                const nx_json* ball = nx_json_item(balls, i);
+
+                Vec2 p;
+                if(!table_file_parse_vec2(ball, "position", &p)) {
+                    FURI_LOG_E(TAG, "Ball missing \"position\", skipping");
+                    continue;
+                }
+
+                int r = DEF_BALL_RADIUS;
+                table_file_parse_int(ball, "radius", &r);
+
+                Vec2 v = (Vec2){0, 0};
+                table_file_parse_vec2(ball, "velocity", &v);
+
+                Ball new_ball(p, r);
+                new_ball.accelerate(v);
+
+                table->balls_initial.push_back(new_ball);
+                table->balls.push_back(new_ball);
+            }
+        }
+        if(table->balls.size() == 0) {
+            FURI_LOG_E(TAG, "Table has NO BALLS");
+            snprintf(pb->text, 256, "No balls\nfound in\ntable file!");
+            delete table;
+            table = NULL;
+            break;
+        }
+
+        // TODO: plungers need work
+        const nx_json* plunger = nx_json_get(json, "plunger");
+        if(plunger) {
+            Vec2 p;
+            table_file_parse_vec2(plunger, "position", &p);
+            int s = 100;
+            table_file_parse_int(plunger, "size", &s);
+            table->plunger = new Plunger(p);
+        } else {
+            FURI_LOG_E(TAG, "Table has NO PLUNGER");
+        }
+
+        const nx_json* flippers = nx_json_get(json, "flippers");
+        if(flippers) {
+            for(int i = 0; i < flippers->children.length; i++) {
+                const nx_json* flipper = nx_json_item(flippers, i);
+
+                Vec2 p;
+                if(!table_file_parse_vec2(flipper, "position", &p)) {
+                    FURI_LOG_E(TAG, "Flipper missing \"position\", skipping");
+                    continue;
+                }
+
+                const nx_json* side = nx_json_get(flipper, "side");
+                Flipper::Side sd = Flipper::LEFT;
+                if(side && !strcmp(side->text_value, "RIGHT")) {
+                    sd = Flipper::RIGHT;
+                }
+
+                int sz = DEF_FLIPPER_SIZE;
+                table_file_parse_int(flipper, "size", &sz);
+                Flipper flip(p, sd, sz);
+                table->flippers.push_back(flip);
+            }
+        }
+
+        const nx_json* bumpers = nx_json_get(json, "bumpers");
+        if(bumpers) {
+            for(int i = 0; i < bumpers->children.length; i++) {
+                const nx_json* bumper = nx_json_item(bumpers, i);
+
+                Vec2 p;
+                if(!table_file_parse_vec2(bumper, "position", &p)) {
+                    FURI_LOG_E(TAG, "Bumper missing \"position\", skipping");
+                    continue;
+                }
+
+                int r = DEF_BUMPER_RADIUS;
+                table_file_parse_int(bumper, "radius", &r);
+
+                float bnc = DEF_BUMPER_BOUNCE;
+                table_file_parse_float(bumper, "bounce", &bnc);
+
+                Bumper* new_bumper = new Bumper(p, r);
+                FURI_LOG_I(TAG, "new bumper: %.3f,%.3f", (double)p.x, (double)p.y);
+                new_bumper->bounce = bnc;
+                table->objects.push_back(new_bumper);
+            }
+        }
+
+        constexpr float pi_180 = M_PI / 180;
+        const nx_json* arcs = nx_json_get(json, "arcs");
+        if(arcs) {
+            for(int i = 0; i < arcs->children.length; i++) {
+                const nx_json* arc = nx_json_item(arcs, i);
+
+                Vec2 p;
+                if(!table_file_parse_vec2(arc, "position", &p)) {
+                    FURI_LOG_E(TAG, "Arc missing \"position\"");
+                    continue;
+                }
+
+                int r = DEF_BUMPER_RADIUS;
+                table_file_parse_int(arc, "radius", &r);
+
+                float bnc = 0.95f; // DEF_BUMPER_BOUNCE?
+                table_file_parse_float(arc, "bounce", &bnc);
+
+                float start_angle = 0.0;
+                table_file_parse_float(arc, "start_angle", &start_angle);
+                start_angle *= pi_180;
+                float end_angle = 0.0;
+                table_file_parse_float(arc, "end_angle", &end_angle);
+                end_angle *= pi_180;
+
+                Arc::Surface surface = Arc::OUTSIDE;
+                const nx_json* stype = nx_json_get(arc, "surface");
+                if(stype && !strcmp(stype->text_value, "INSIDE")) {
+                    surface = Arc::INSIDE;
+                }
+
+                Arc* new_bumper = new Arc(p, r, start_angle, end_angle, surface);
+                new_bumper->bounce = bnc;
+                table->objects.push_back(new_bumper);
+            }
+        }
+
+        const nx_json* rails = nx_json_get(json, "rails");
+        if(rails) {
+            for(int i = 0; i < rails->children.length; i++) {
+                const nx_json* rail = nx_json_item(rails, i);
+
+                Vec2 s;
+                if(!table_file_parse_vec2(rail, "start", &s)) {
+                    FURI_LOG_E(TAG, "Rail missing \"start\", skipping");
+                    continue;
+                }
+                Vec2 e;
+                if(!table_file_parse_vec2(rail, "end", &e)) {
+                    FURI_LOG_E(TAG, "Rail missing \"end\", skipping");
+                    continue;
+                }
+
+                Polygon* new_rail = new Polygon();
+                new_rail->add_point(s);
+                new_rail->add_point(e);
+
+                float bnc = DEF_RAIL_BOUNCE;
+                table_file_parse_float(rail, "bounce", &bnc);
+                new_rail->bounce = bnc;
+
+                int double_sided = 0;
+                table_file_parse_int(rail, "double_sided", &double_sided);
+
+                new_rail->finalize();
+                table->objects.push_back(new_rail);
+
+                if(double_sided) {
+                    new_rail = new Polygon();
+                    new_rail->add_point(e);
+                    new_rail->add_point(s);
+                    new_rail->bounce = bnc;
+                    new_rail->finalize();
+                    table->objects.push_back(new_rail);
+                }
+            }
+        }
+
+        const nx_json* portals = nx_json_get(json, "portals");
+        if(portals) {
+            for(int i = 0; i < portals->children.length; i++) {
+                const nx_json* portal = nx_json_item(portals, i);
+
+                Vec2 a1;
+                if(!table_file_parse_vec2(portal, "a_start", &a1)) {
+                    FURI_LOG_E(TAG, "Portal missing \"a_start\", skipping");
+                    continue;
+                }
+                Vec2 a2;
+                if(!table_file_parse_vec2(portal, "a_end", &a2)) {
+                    FURI_LOG_E(TAG, "Portal missing \"a_end\", skipping");
+                    continue;
+                }
+                Vec2 b1;
+                if(!table_file_parse_vec2(portal, "b_start", &b1)) {
+                    FURI_LOG_E(TAG, "Portal missing \"b_start\", skipping");
+                    continue;
+                }
+                Vec2 b2;
+                if(!table_file_parse_vec2(portal, "b_end", &b2)) {
+                    FURI_LOG_E(TAG, "Portal missing \"b_end\", skipping");
+                    continue;
+                }
+
+                Portal* new_portal = new Portal(a1, a2, b1, b2);
+                new_portal->finalize();
+                table->objects.push_back(new_portal);
+            }
+        }
+
+        const nx_json* rollovers = nx_json_get(json, "rollovers");
+        if(rollovers) {
+            for(int i = 0; i < rollovers->children.length; i++) {
+                const nx_json* rollover = nx_json_item(rollovers, i);
+
+                Vec2 p;
+                if(!table_file_parse_vec2(rollover, "position", &p)) {
+                    FURI_LOG_E(TAG, "Rollover missing \"position\", skipping");
+                    continue;
+                }
+                char sym = '*';
+                const nx_json* symbol = nx_json_get(rollover, "symbol");
+                if(symbol) {
+                    sym = symbol->text_value[0];
+                }
+                Rollover* new_rollover = new Rollover(p, sym);
+                table->objects.push_back(new_rollover);
+            }
+        }
+
+        const nx_json* turbos = nx_json_get(json, "turbos");
+        if(turbos) {
+            for(int i = 0; i < turbos->children.length; i++) {
+                const nx_json* turbo = nx_json_item(turbos, i);
+
+                Vec2 p;
+                if(!table_file_parse_vec2(turbo, "position", &p)) {
+                    FURI_LOG_E(TAG, "Turbo missing \"position\"");
+                    continue;
+                }
+                float angle = 0;
+                table_file_parse_float(turbo, "angle", &angle);
+                angle *= pi_180;
+
+                float boost = 10;
+                table_file_parse_float(turbo, "boost", &boost);
+
+                Turbo* new_turbo = new Turbo(p, angle, boost);
+
+                table->objects.push_back(new_turbo);
+            }
+        }
+        break;
+    } while(true);
+
+    nx_json_free(json);
+    free(json_buffer);
+
+    return table;
+}
+
+bool table_load_table(void* ctx, size_t index) {
+    PinballState* pb = (PinballState*)ctx;
+
+    // read the index'th file in pb->table_list and allocate
+    FURI_LOG_I(TAG, "Loading table %u", index);
+
+    // if there's already a table loaded, free it
+    if(pb->table) {
+        delete pb->table;
+        pb->table = nullptr;
+    }
+
+    pb->gameStarted = false;
+    switch(index) {
+    case TABLE_SELECT:
+        pb->table = table_init_table_select(ctx);
+        break;
+    case TABLE_ERROR:
+        pb->table = table_init_table_error(ctx);
+        break;
+    default:
+        pb->table = table_load_table_from_file(pb, index - TABLE_INDEX_OFFSET);
+        break;
+    }
+    return pb->table != NULL;
+}
+
+TableList::~TableList() {
+    for(auto& mi : menu_items) {
+        furi_string_free(mi.name);
+        furi_string_free(mi.filename);
+    }
+}
+
+Table* table_init_table_select(void* ctx) {
+    UNUSED(ctx);
+    Table* table = new Table();
+
+    table->balls.push_back(Ball(Vec2(20, 880), 35));
+    table->balls.back().add_velocity(Vec2(7, 0), .10f);
+    table->balls.push_back(Ball(Vec2(610, 920), 30));
+    table->balls.back().add_velocity(Vec2(-8, 0), .10f);
+    table->balls.push_back(Ball(Vec2(250, 980), 20));
+    table->balls.back().add_velocity(Vec2(10, 0), .10f);
+
+    table->balls_released = true;
+
+    Polygon* new_rail = new Polygon();
+    new_rail->add_point({-1, 840});
+    new_rail->add_point({-1, 1280});
+    new_rail->finalize();
+    new_rail->hidden = true;
+    table->objects.push_back(new_rail);
+
+    new_rail = new Polygon();
+    new_rail->add_point({-1, 1280});
+    new_rail->add_point({640, 1280});
+    new_rail->finalize();
+    new_rail->hidden = true;
+    table->objects.push_back(new_rail);
+
+    new_rail = new Polygon();
+    new_rail->add_point({640, 1280});
+    new_rail->add_point({640, 840});
+    new_rail->finalize();
+    new_rail->hidden = true;
+    table->objects.push_back(new_rail);
+
+    int gap = 8;
+    int speed = 3;
+    float top = 20;
+    // right side
+    table->objects.push_back(new Chaser(Vec2(32, top), Vec2(62, top), gap, speed));
+    table->objects.push_back(new Chaser(Vec2(62, top), Vec2(62, 84), gap, speed));
+    table->objects.push_back(new Chaser(Vec2(62, 84), Vec2(32, 84), gap, speed));
+
+    // left side
+    table->objects.push_back(new Chaser(Vec2(32, top), Vec2(1, top), gap, speed));
+    table->objects.push_back(new Chaser(Vec2(1, top), Vec2(1, 84), gap, speed));
+    table->objects.push_back(new Chaser(Vec2(1, 84), Vec2(32, 84), gap, speed));
+
+    return table;
+}
+
+Table* table_init_table_error(void* ctx) {
+    UNUSED(ctx);
+    // PinballState* pb = (PinballState*)ctx;
+    Table* table = new Table();
+
+    table->balls.push_back(Ball(Vec2(20, 880), 30));
+    table->balls.back().add_velocity(Vec2(7, 0), .10f);
+    // table->balls.push_back(Ball(Vec2(610, 920), 30));
+    // table->balls.back().add_velocity(Vec2(-8, 0), .10f);
+    // table->balls.push_back(Ball(Vec2(250, 980), 20));
+    // table->balls.back().add_velocity(Vec2(10, 0), .10f);
+
+    table->balls_released = true;
+
+    Polygon* new_rail = new Polygon();
+    new_rail->add_point({-1, 840});
+    new_rail->add_point({-1, 1280});
+    new_rail->finalize();
+    new_rail->hidden = true;
+    table->objects.push_back(new_rail);
+
+    new_rail = new Polygon();
+    new_rail->add_point({-1, 1280});
+    new_rail->add_point({640, 1280});
+    new_rail->finalize();
+    new_rail->hidden = true;
+    table->objects.push_back(new_rail);
+
+    new_rail = new Polygon();
+    new_rail->add_point({640, 1280});
+    new_rail->add_point({640, 840});
+    new_rail->finalize();
+    new_rail->hidden = true;
+    table->objects.push_back(new_rail);
+
+    int gap = 8;
+    int speed = 3;
+    float top = 20;
+
+    table->objects.push_back(new Chaser(Vec2(2, top), Vec2(61, top), gap, speed, Chaser::SLASH));
+    table->objects.push_back(new Chaser(Vec2(2, top), Vec2(2, 84), gap, speed, Chaser::SLASH));
+    table->objects.push_back(new Chaser(Vec2(2, 84), Vec2(61, 84), gap, speed, Chaser::SLASH));
+    table->objects.push_back(new Chaser(Vec2(61, top), Vec2(61, 84), gap, speed, Chaser::SLASH));
+
+    return table;
+}

+ 54 - 0
table.h

@@ -0,0 +1,54 @@
+#pragma once
+
+#include <furi.h>
+#include <vector>
+#include "objects.h"
+
+#define TABLE_SELECT       0
+#define TABLE_ERROR        1
+#define TABLE_INDEX_OFFSET 2
+
+// Defines all of the elements on a pinball table:
+// edges, bumpers, flipper locations, scoreboard
+// eventually read stae from file and dynamically allocate
+class Table {
+public:
+    Table()
+        : balls_released(false)
+        , num_lives(1)
+        , plunger(nullptr) {
+    }
+
+    ~Table();
+    std::vector<FixedObject*> objects;
+    std::vector<Ball> balls; // current state of balls
+    std::vector<Ball> balls_initial; // original positions, before release
+    std::vector<Flipper> flippers;
+
+    bool balls_released; // is ball in play?
+    size_t num_lives;
+    Vec2 num_lives_pos;
+    size_t score;
+
+    Plunger* plunger;
+
+    void draw(Canvas* canvas);
+};
+
+typedef struct {
+    FuriString* name;
+    FuriString* filename;
+} TableMenuItem;
+
+class TableList {
+public:
+    ~TableList();
+    std::vector<TableMenuItem> menu_items;
+    size_t display_size; // how many can fit on screen
+    size_t selected;
+};
+
+// Read the list tables from the data folder and store in the state
+void table_table_list_init(void* ctx);
+// Loads the index'th table from the list
+bool table_load_table(void* ctx, size_t index);

+ 185 - 0
vec2.cxx

@@ -0,0 +1,185 @@
+#include <stdbool.h>
+#include <math.h>
+
+#include "vec2.h"
+
+// Vec2 Vec2_unit(const Vec2& v) {
+//     Vec2 u(v.x, v.y);
+//     u.normalize();
+//     return u;
+// }
+
+// // Returns true if point b lies between points a and c
+// bool Vec2_colinear(const Vec2& a, const Vec2& b, const Vec2& c) {
+//     float ac = a.dist(c);
+//     float ab = a.dist(b);
+//     float bc = b.dist(c);
+//     return fabsf(ac - ab - bc) <= VEC2_EPSILON;
+// }
+
+// Returns the closest point to the line segment ab and p
+Vec2 Vec2_closest(const Vec2& a, const Vec2& b, const Vec2& p) {
+    // vector along line ab
+    Vec2 ab = b - a;
+    float t = ab.dot(ab);
+    if(t == 0.0f) {
+        return a;
+    }
+    t = fmax(0.0f, fmin(1.0f, (p.dot(ab) - a.dot(ab)) / t));
+    return a + ab * t;
+}
+
+// // // Ehhh - this should work??
+// // // Returns the closest point to the line segment ab and p
+// // Vec2 Vec2_closest(const Vec2& a, const Vec2& b, const Vec2& p) {
+// //     // vector along line ab
+// //     Vec2 ab = b - a;
+// //     // vector aline line ap
+// //     Vec2 ap = p - a;
+// //     // projection of p onto ab
+// //     float proj = ap.dot(ab);
+// //     // line segment ab length, squared
+// //     float ab_l2 = a.dist2(b);
+// //     // ratio of the projection along line. segment is [0,1] - beyond isn't on line
+// //     float d = proj / ab_l2;
+// //     if(d <= 0) {
+// //         return a;
+// //     } else if(d >= 1) {
+// //         return b;
+// //     }
+// //     return a + ab * d;
+// // }
+
+// // Returns the closest point to the infinite ray ab and p
+// Vec2 Vec2_closest_ray(const Vec2& a, const Vec2& b, const Vec2& p) {
+//     // vector along line ab
+//     Vec2 ab = b - a;
+//     // vector along line ap
+//     Vec2 ap = p - a;
+//     // projection of p onto ab
+//     float proj = ap.dot(ab);
+//     // line segment ab length, squared
+//     float ab_l2 = a.dist2(b);
+//     // ratio of the projection along line. segment is [0,1] - beyond isn't on line
+//     float d = proj / ab_l2;
+//     return a + ab * d;
+// }
+
+// // Returns if A, B, C are listed in counterclockwise order
+// // Also tells us if C is "to the left of" line AB
+// bool Vec2_ccw(const Vec2& A, const Vec2& B, const Vec2& C) {
+//     return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x);
+// }
+
+// // Returns true if line AB intersects with line CD
+// bool Vec2_intersect(const Vec2& A, const Vec2& B, const Vec2& C, const Vec2& D) {
+//     return Vec2_ccw(A, C, D) != Vec2_ccw(B, C, D) && Vec2_ccw(A, B, C) != Vec2_ccw(A, B, D);
+// }
+
+// // Returns intersection point of two lines AB and CD.
+// // The intersection point may not lie on either segment.
+// Vec2 Vec2_intersection(const Vec2& A, const Vec2& B, const Vec2& C, const Vec2& D) {
+//     float a = (D.x - C.x) * (C.y - A.y) - (D.y - C.y) * (C.x - A.x);
+//     float b = (D.x - C.x) * (B.y - A.y) - (D.y - C.y) * (B.x - A.x);
+//     // int c = (B.x - A.x) * (C.y - A.y) - (B.y - A.y) * (C.x - A.x);
+//     if(b == 0) {
+//         // lines are parallel
+//         return (Vec2){NAN, NAN};
+//     }
+//     float alpha = a / b;
+//     // float beta = c / b;
+//     return (Vec2){A.x + alpha * (B.x - A.x), A.y + alpha * (B.y - A.y)};
+// }
+
+// // Returns distance of ray origin to intersection point on line segment. -1 if no intersection
+// float Vec2_ray_line_segment_intersect(
+//     const Vec2& origin,
+//     const Vec2& dir,
+//     const Vec2& l1,
+//     const Vec2& l2) {
+//     Vec2 v1 = origin - l1;
+//     Vec2 v2 = l2 - l1;
+//     Vec2 v3 = Vec2(-dir.y, dir.x);
+
+//     float dot = v2.dot(v3);
+//     if(fabsf(dot) < (float)0.00001) {
+//         return -1.0f;
+//     }
+
+//     float t1 = v2.cross(v1) / dot;
+//     float t2 = v1.dot(v3) / dot;
+//     if(t1 >= 0 && (t2 >= 0 && t2 <= 1)) {
+//         return t1;
+//     }
+//     return -1.0f;
+// }
+
+// // Projects P onto AB and returns true if P lies on line segment AB
+// // [dst] is the location of P projected onto AB
+// bool Vec2_project(const Vec2& A, const Vec2& B, const Vec2& P, Vec2& dst) {
+//     Vec2 AB(B - A);
+//     Vec2 AP(P - A);
+//     float k = AP.dot(AB) / AB.dot(AB);
+//     if(k < 0 || k > 1) return false;
+//     dst = A + AB * k;
+//     return true;
+// }
+
+// bool Vec2_point_circle_collide(const Vec2& point, const Vec2& circle, float radius) {
+//     Vec2 d = {circle.x - point.x, circle.y - point.y};
+//     return d.mag() <= radius * radius;
+// }
+
+// bool Vec2_line_circle_collide(
+//     const Vec2& a,
+//     const Vec2& b,
+//     const Vec2& circle,
+//     float radius,
+//     Vec2* nearest) {
+//     // check if start or end points lie within circle
+//     if(Vec2_point_circle_collide(a, circle, radius)) {
+//         if(nearest) {
+//             *nearest = a;
+//         }
+//         return true;
+//     }
+//     if(Vec2_point_circle_collide(b, circle, radius)) {
+//         if(nearest) {
+//             *nearest = b;
+//         }
+//         return true;
+//     }
+
+//     float x1 = a.x;
+//     float y1 = a.y;
+//     float x2 = b.x;
+//     float y2 = b.y;
+//     float cx = circle.x;
+//     float cy = circle.y;
+
+//     float dx = x2 - x1;
+//     float dy = y2 - y1;
+
+//     float lcx = cx - x1;
+//     float lcy = cy - y1;
+
+//     // project lc onto d, resulting in vector p
+//     float d_len2 = dx * dx + dy * dy;
+//     float px = dx;
+//     float py = dy;
+//     if(d_len2 > 0) {
+//         float dp = (lcx * dx + lcy * dy) / d_len2;
+//         px *= dp;
+//         py *= dp;
+//     }
+
+//     Vec2 nearest_tmp(x1 + px, y1 + py);
+//     float p_len2 = px * px + py * py;
+
+//     if(nearest) {
+//         *nearest = nearest_tmp;
+//     }
+
+//     return Vec2_point_circle_collide(nearest_tmp, circle, radius) && p_len2 <= d_len2 &&
+//            (px * dx + py * dy) >= 0;
+// }

+ 147 - 0
vec2.h

@@ -0,0 +1,147 @@
+#pragma once
+#include <stdbool.h>
+#include <cmath>
+
+#define VEC2_EPSILON (float)0.001
+
+class Vec2 {
+public:
+    float x;
+    float y;
+
+    Vec2()
+        : x(0)
+        , y(0) {
+    }
+    Vec2(float x_, float y_)
+        : x(x_)
+        , y(y_) {
+    }
+
+    Vec2 operator+(const Vec2& rhs) const {
+        return Vec2(x + rhs.x, y + rhs.y);
+    }
+    Vec2 operator+(float s) const {
+        return Vec2(x + s, y + s);
+    }
+    void operator+=(const Vec2& rhs) {
+        x += rhs.x;
+        y += rhs.y;
+    }
+    Vec2 operator-(const Vec2& rhs) const {
+        return Vec2(x - rhs.x, y - rhs.y);
+    }
+    Vec2 operator-(float s) const {
+        return Vec2(x - s, y - s);
+    }
+    void operator-=(const Vec2& rhs) {
+        x -= rhs.x;
+        y -= rhs.y;
+    }
+    Vec2 operator*(float s) const {
+        return Vec2(x * s, y * s);
+    }
+    void operator*=(float s) {
+        x *= s;
+        y *= s;
+    }
+
+    Vec2 operator/(float s) const {
+        return Vec2(x / s, y / s);
+    }
+
+    bool operator==(const Vec2& rhs) const {
+        return x == rhs.x && y == rhs.y;
+    }
+
+    // Magnitude / length of vector
+    float mag() const {
+        return sqrtf(x * x + y * y);
+    }
+    // Magnitude squared
+    float mag2() const {
+        return x * x + y * y;
+    }
+
+    // Dot product: this.x * v.x + this.y * v.y
+    float dot(const Vec2& v) const {
+        return x * v.x + y * v.y;
+    }
+
+    // Cross product
+    float cross(const Vec2& v) const {
+        return x * v.y - y * v.x;
+    }
+
+    void normalize(void) {
+        float len = mag();
+        if(len > VEC2_EPSILON) {
+            float inverse_len = 1.0f / len;
+            x *= inverse_len;
+            y *= inverse_len;
+        }
+    }
+
+    // Distance squared between this and next
+    float dist2(const Vec2& v) const {
+        float dx = x - v.x;
+        float dy = y - v.y;
+        return dx * dx + dy * dy;
+    }
+    // Distance between tihs and next
+    float dist(const Vec2& v) const {
+        return sqrtf(dist2(v));
+    }
+
+    // void rotate(float radians) {
+    //     float c = std::cos(radians);
+    //     float s = std::sin(radians);
+    //     float xp = x * c - y * s;
+    //     float yp = x * s + y * c;
+    //     x = xp;
+    //     y = yp;
+    // }
+};
+
+inline Vec2 operator*(float s, const Vec2& v) {
+    return Vec2(s * v.x, s * v.y);
+}
+
+// Vec2 Vec2_unit(const Vec2& v);
+
+// // Returns true if point b lies between points a and c
+// bool Vec2_colinear(const Vec2& a, const Vec2& b, const Vec2& c);
+
+// // Returns the closest point to the line segment ab and p
+Vec2 Vec2_closest(const Vec2& a, const Vec2& b, const Vec2& p);
+
+// // Returns the closest point to the infinite ray ab and p
+// Vec2 Vec2_closest_ray(const Vec2& a, const Vec2& b, const Vec2& p);
+
+// // Returns if A, B, C are listed in counterclockwise order
+// bool Vec2_ccw(const Vec2& A, const Vec2& B, const Vec2& C);
+
+// // Returns if line AB intersects with line CD
+// bool Vec2_intersect(const Vec2& A, const Vec2& B, const Vec2& C, const Vec2& D);
+
+// // Returns intersection point of two lines AB and CD.
+// // The intersection point may not lie on either segment.
+// Vec2 Vec2_intersection(const Vec2& A, const Vec2& B, const Vec2& C, const Vec2& D);
+
+// // Returns distance of ray origin to intersection point on line segment. -1 if no intersection
+// float Vec2_ray_line_segment_intersect(
+//     const Vec2& origin,
+//     const Vec2& dir,
+//     const Vec2& l1,
+//     const Vec2& l2);
+
+// bool Vec2_project(const Vec2& A, const Vec2& B, const Vec2& P, Vec2& dst);
+
+// bool Vec2_point_circle_collide(const Vec2& point, const Vec2& circle, float radius);
+
+// bool Vec2_line_circle_collide(
+//     const Vec2 a,
+//     const Vec2 b,
+//     const Vec2 circle,
+//     float radius,
+//     Vec2* nearest);