Преглед изворни кода

Add pinball0 from https://github.com/rdefeo/pinball0

git-subtree-dir: pinball0
git-subtree-mainline: 97e22396acf6903f7583c004fd21512d46981787
git-subtree-split: 43f7553255c101c281a42bf6d07fc1ae65d655e6
Willy-JL пре 1 година
родитељ
комит
9dc372386d
60 измењених фајлова са 5349 додато и 0 уклоњено
  1. 1 0
      pinball0/.github/FUNDING.yml
  2. 24 0
      pinball0/.github/ISSUE_TEMPLATE/bug_report.md
  3. 18 0
      pinball0/.github/ISSUE_TEMPLATE/new-table-template.md
  4. 41 0
      pinball0/.github/workflows/build.yml
  5. 8 0
      pinball0/.gitignore
  6. 1 0
      pinball0/.gitsubtree
  7. 22 0
      pinball0/CHANGELOG.md
  8. 124 0
      pinball0/README.md
  9. 29 0
      pinball0/README_flipperlab.md
  10. 19 0
      pinball0/application.fam
  11. 143 0
      pinball0/assets/tables/01_Basic.json
  12. 143 0
      pinball0/assets/tables/02_Classic.json
  13. 76 0
      pinball0/assets/tables/03_El Ocho.json
  14. 114 0
      pinball0/assets/tables/04_Chamber.json
  15. 85 0
      pinball0/assets/tables/05_Endless.json
  16. 19 0
      pinball0/assets/tables/40_dbg Arc Test.json
  17. 60 0
      pinball0/assets/tables/50_dbg Bumpers.json
  18. 49 0
      pinball0/assets/tables/70_dbg Platforms.json
  19. 16 0
      pinball0/assets/tables/95_dbg Error.json
  20. 528 0
      pinball0/graphics.cxx
  21. 29 0
      pinball0/graphics.h
  22. 0 0
      pinball0/images/.gitkeep
  23. BIN
      pinball0/images/Arcade_A.png
  24. BIN
      pinball0/images/Arcade_E.png
  25. BIN
      pinball0/images/Arcade_G.png
  26. BIN
      pinball0/images/Arcade_I.png
  27. BIN
      pinball0/images/Arcade_L.png
  28. BIN
      pinball0/images/Arcade_M.png
  29. BIN
      pinball0/images/Arcade_O.png
  30. BIN
      pinball0/images/Arcade_R.png
  31. BIN
      pinball0/images/Arcade_T.png
  32. BIN
      pinball0/images/Arcade_V.png
  33. BIN
      pinball0/images/pinball0_logo.png
  34. 276 0
      pinball0/notifications.cxx
  35. 22 0
      pinball0/notifications.h
  36. 142 0
      pinball0/nxjson/README.md
  37. 414 0
      pinball0/nxjson/nxjson.c
  38. 89 0
      pinball0/nxjson/nxjson.h
  39. 670 0
      pinball0/objects.cxx
  40. 295 0
      pinball0/objects.h
  41. 661 0
      pinball0/pinball0.cxx
  42. 87 0
      pinball0/pinball0.h
  43. BIN
      pinball0/pinball0.png
  44. BIN
      pinball0/screenshots/lab_basic.png
  45. BIN
      pinball0/screenshots/lab_classic.png
  46. BIN
      pinball0/screenshots/lab_el_ocho.png
  47. BIN
      pinball0/screenshots/lab_menu.png
  48. BIN
      pinball0/screenshots/lab_splash.png
  49. BIN
      pinball0/screenshots/screenshot_basic.png
  50. BIN
      pinball0/screenshots/screenshot_chamber.png
  51. BIN
      pinball0/screenshots/screenshot_el_ocho.png
  52. BIN
      pinball0/screenshots/screenshot_menu.png
  53. BIN
      pinball0/screenshots/splash.png
  54. 100 0
      pinball0/settings.cxx
  55. 18 0
      pinball0/settings.h
  56. 243 0
      pinball0/table.cxx
  57. 88 0
      pinball0/table.h
  58. 577 0
      pinball0/table_parser.cxx
  59. 16 0
      pinball0/vec2.cxx
  60. 102 0
      pinball0/vec2.h

+ 1 - 0
pinball0/.github/FUNDING.yml

@@ -0,0 +1 @@
+buy_me_a_coffee: rdefeo

+ 24 - 0
pinball0/.github/ISSUE_TEMPLATE/bug_report.md

@@ -0,0 +1,24 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is. Is this a game play bug? A build/compilation bug? More information is better.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+
+**Logs**
+Please include log output - this can be extremely helpful. To capture the logs, you will need to run `ufbt cli` and then the `log` command. Pinball0 will print various levels of logging data to the console. You can learn more about ufbt here: [github.com/flipperdevices/flipperzero-ufbt](https://github.com/flipperdevices/flipperzero-ufbt)
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Software Info**
+ - Pinball0 version [e.g. v0.2]
+ - ufbt version [e.g. 1.1.2] - if this is a build issue

+ 18 - 0
pinball0/.github/ISSUE_TEMPLATE/new-table-template.md

@@ -0,0 +1,18 @@
+---
+name: New table template
+about: Suggest a new table. Have you posted this on Discussions yet?
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Screenshots**
+A picture is worth a thousand words! Show off your table with a screenshot.
+
+**Files**
+Include the json file of your new table
+
+**Description**
+- Name of your table
+- Reason why you feel it should be included with the shipped versions of Pinball0

+ 41 - 0
pinball0/.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
pinball0/.gitignore

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

+ 1 - 0
pinball0/.gitsubtree

@@ -0,0 +1 @@
+https://github.com/rdefeo/pinball0 master /

+ 22 - 0
pinball0/CHANGELOG.md

@@ -0,0 +1,22 @@
+## 0.4.0
+
+- Table Tilt!
+- Solid flipper rendering
+- Code refactor / cleanup
+
+## 0.3.0
+
+- Added Idle timeout of 2 minutes
+- Changed Manual mode to Debug mode
+
+## 0.2.0
+
+- User tables from /apps_data/pinball0 folder
+- Sounds, LED blinking, vibrations
+- Basic scores
+- Collision bug fixes
+- Mem leak fix
+
+## 0.1.0
+
+- BETA release

+ 124 - 0
pinball0/README.md

@@ -0,0 +1,124 @@
+# Pinball0 (Pinball Zero)
+Play pinball on your Flipperzero!
+
+Still a work in progress...
+
+[Latest version v0.4](https://github.com/rdefeo/pinball0/releases)
+
+![build status badge](https://github.com/rdefeo/pinball0/actions/workflows/build.yml/badge.svg)
+
+> 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)
+
+## Features
+* Realistic physics and collisions
+* User-defined tables via JSON files
+* Bumpers, flat surfaces, curved surfaces
+* Scores! (no high scores yet, just a running tally as you play)
+* Table bumps! (Don't tilt the table!)
+* Portals!
+* Rollover items
+* Sounds! Blinky lights! Annoying vibrations!
+* Customizable notification settings: sound, LED, vibration
+* Idle timeout to save battery - will exit after ~120 seconds of no key-presses
+
+## 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
+
+I find it easiest to hold the flipper with both hands so I can hit left/right with my thumbs!
+
+## Settings
+The **SETTINGS** menu will be the "last" table listed. You can Enable / Disable the following: Sound, LED light, Vibration, and Debug mode. Move Up/Down to select your setting and press **OK** to toggle. Settings are saved in `/data/.pinball0.conf` as a native Flipper Format file. **Back** will return you to the main menu.
+
+**Debug** mode allows you to move the ball using the directional pad _before_ the ball is launched. This is useful for testing and may be removed in the future. (May result in unexpected behavior.) It also displays test tables on the main menu. The test tables will only show/hide after you exit and restart the app. This feature is mainly for me - lol.
+
+## Tables
+Pinball0 ships with several default tables. These tables are automatically deployed into the assets folder (`/apps_data/pinball0`) on your SD card. Tables are simple JSON which means you can define your own! Your tables should be stored in the data folder (`/apps_data/pinball0`). On the main menu, tables are sorted alphabetically. In order to "force" a sorting order, you can prepend any filename with `NN_` where `NN` is between `00` and `99`. When the files are displayed on the menu, if they start with `NN_`, that will be stripped - but their sorted order will be preserved.
+
+> The default tables may change over time.
+
+In **Debug** mode, test tables will be shown. A test table is one that begins with the text `dbg`. Given that you can prefix table names for sorting purposes, here are two valid table filenames for a test table called `my FLIPS`: `dbg my FLIPS.json` and `04_dbg my FLIPS.json`. In both cases it will be displayed as `dbg my FLIPS` on the menu. I doubt that you will use this feature, but I'm documenting it anyway.
+
+
+### 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. Check out the default tables in the `assets/tables` folder for example usage.
+
+These JSON elements are all defined at the top-level. 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. These logs are useful when reporting bugs/issues!
+
+#### lives : object (optional)
+Defines how many lives/balls you start with, and display information
+
+* `"display": bool` : optional, defaults to false
+* `"position": [ X, Y ]`
+* `"value": N` : Number of balls - optional, defaults to 3
+* `"align": A` : optional, defaults to `"HORIZONTAL"` (also, `"VERTICAL"`)
+
+#### 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 ]`. The default tables have an initial velocity magnitude in the 10-18 range. Test your own values!
+* `"radius" : N` : optional, defaults to `20`
+
+#### score : object (optional)
+* `"display" : bool` : optional, defaults to false
+* `"position" : [ X, Y ]` : optional, defaults to `[ 63, 0 ]`
+
+The position units are in absolute LCD pixels within the range [0..63], [0..127].
+
+#### flippers : list of objects (optional)
+* `"position": [ X, Y ]` : location of the pivot point
+* `"side": S` : valid values are `"LEFT"` or `"RIGHT"`
+* `"size": N` : optional, defaults to `120`
+
+You can have more than 2 flippers! Try it!
+
+#### bumpers : list of objects (optional)
+* `"position": [ X, Y ]`
+* `"radius": [ N ]` : optional, defaults to `40`
+
+#### rails : list of objects (optional)
+* `"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 (optional)
+* `"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 (optional)
+* `"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 (optional)
+* `"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.
+
+#### tilt_detect : boolean
+* `"tilt_detect": bool` : optional, defaults to `true`
+
+Mainly used to turn off tilt detection. Useful for tables that promote free-play and multiple table bumps without penalty.

+ 29 - 0
pinball0/README_flipperlab.md

@@ -0,0 +1,29 @@
+# Pinball0 (Pinball Zero)
+Play pinball on your Flipperzero!
+
+## Features
+* Realistic physics and collisions
+* User-defined tables via JSON files
+* Bumpers, flat surfaces, curved surfaces
+* Table bumps
+* Portals!
+* Rollover items
+* Sounds! Blinky lights! Annoying vibrations!
+* Customizable notification settings: sound, LED, vibration
+* Idle timeout
+
+## 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
+
+I find it easiest to hold the flipper with both hands so I can hit left/right with my thumbs!
+
+## Tables
+Pinball0 ships with several default tables. These tables are automatically deployed into the assets folder (/apps_data/pinball0). Tables are simple JSON which means you can define your own! Your tables should be stored in the data folder (/apps_data/pinball0). 
+
+View the github repo for the JSON format specification: https://github.com/rdefeo/pinball0
+
+**The default tables may change over time.**
+

+ 19 - 0
pinball0/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,  # neede?
+    fap_category="Games",
+    requires=["gui"],
+    # Optional values
+    fap_version="0.4",
+    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",
+)

+ 143 - 0
pinball0/assets/tables/01_Basic.json

@@ -0,0 +1,143 @@
+{
+    "name": "Basic",
+    "lives": {
+        "display": true
+    },
+    "score": {
+        "display": true
+    },
+    "balls": [
+        {
+            "position": [ 600, 1110 ],
+            "velocity": [ 0, -16.0 ]
+        }
+    ],
+    // "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 ]
+        }
+    ]
+}

+ 143 - 0
pinball0/assets/tables/02_Classic.json

@@ -0,0 +1,143 @@
+{
+    "name": "Classic",
+    "lives": {
+        "display": true,
+        "position": [ 20, 480 ],
+        "align": "VERTICAL"
+    },
+    "score": {
+        "display": true,
+        "position": [ 23, 0 ]
+    },
+    "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, 400 ]
+        },
+        {
+            "start": [ 0, 740 ],
+            "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 ],
+            "bounce": 1.08
+        },
+        {
+            "start": [ 80, 480 ],
+            "end": [ 80, 660 ],
+            "bounce": 1.1
+        },
+        {
+            "start": [ 80, 660 ],
+            "end": [ 0, 740 ],
+            "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"
+        }
+    ]
+}

+ 76 - 0
pinball0/assets/tables/03_El Ocho.json

@@ -0,0 +1,76 @@
+{
+    "name": "El Ocho",
+    "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
pinball0/assets/tables/04_Chamber.json

@@ -0,0 +1,114 @@
+{
+    "name": "Chamber",
+    "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 ]
+        }
+    ]
+}

+ 85 - 0
pinball0/assets/tables/05_Endless.json

@@ -0,0 +1,85 @@
+{
+    "name": "Endless",
+    "lives": {
+        "value": 1
+    },
+    "tilt_detect": false,
+    "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 ]
+        }
+    ]
+}

+ 19 - 0
pinball0/assets/tables/40_dbg Arc Test.json

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

+ 60 - 0
pinball0/assets/tables/50_dbg Bumpers.json

@@ -0,0 +1,60 @@
+{
+    "balls": [
+        {
+            "position": [ 250, 50 ]
+        }
+    ],
+    "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 ]
+        }
+    ]
+}

+ 49 - 0
pinball0/assets/tables/70_dbg Platforms.json

@@ -0,0 +1,49 @@
+{
+    "name": "Platforms",
+    "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 ]
+        }
+    ]
+}

+ 16 - 0
pinball0/assets/tables/95_dbg Error.json

@@ -0,0 +1,16 @@
+{
+    "balls": [
+        {
+            // oh noes! we don't have a position!
+        }
+    ],
+    "arcs": [
+        {
+            "position": [ 320, 800 ],
+            "radius": 310,
+            "start_angle": 90,
+            "end_angle": 360,
+            "surface": "INSIDE"
+        }
+    ]
+}

+ 528 - 0
pinball0/graphics.cxx

@@ -0,0 +1,528 @@
+#include "graphics.h"
+
+#define SCALE 10
+
+namespace {
+
+// Another algo - https://www.research-collection.ethz.ch/handle/20.500.11850/68976
+
+/*
+ * Thick line methods courtesy
+ * https://github.com/ArminJo/Arduino-BlueDisplay/blob/master/src/LocalGUI/ThickLine.hpp 
+ */
+const int LOCAL_DISPLAY_WIDTH = 64;
+const int LOCAL_DISPLAY_HEIGHT = 128;
+/*
+ * Overlap means drawing additional pixel when changing minor direction
+ * Needed for drawThickLine, otherwise some pixels will be missing in the thick line
+ */
+const int LINE_OVERLAP_NONE = 0; // No line overlap, like in standard Bresenham
+const int LINE_OVERLAP_MAJOR =
+    0x01; // Overlap - first go major then minor direction. Pixel is drawn as extension after actual line
+const int LINE_OVERLAP_MINOR =
+    0x02; // Overlap - first go minor then major direction. Pixel is drawn as extension before next line
+const int LINE_OVERLAP_BOTH = 0x03; // Overlap - both
+
+const int LINE_THICKNESS_MIDDLE = 0; // Start point is on the line at center of the thick line
+const int LINE_THICKNESS_DRAW_CLOCKWISE = 1; // Start point is on the counter clockwise border line
+const int LINE_THICKNESS_DRAW_COUNTERCLOCKWISE = 2; // Start point is on the clockwise border line
+
+/**
+ * Draws a line from aXStart/aYStart to aXEnd/aYEnd including both ends
+ * @param aOverlap One of LINE_OVERLAP_NONE, LINE_OVERLAP_MAJOR, LINE_OVERLAP_MINOR, LINE_OVERLAP_BOTH
+ */
+void drawLineOverlap(
+    Canvas* canvas,
+    unsigned int aXStart,
+    unsigned int aYStart,
+    unsigned int aXEnd,
+    unsigned int aYEnd,
+    uint8_t aOverlap) {
+    int16_t tDeltaX, tDeltaY, tDeltaXTimes2, tDeltaYTimes2, tError, tStepX, tStepY;
+
+    /*
+     * Clip to display size
+     */
+    if(aXStart >= LOCAL_DISPLAY_WIDTH) {
+        aXStart = LOCAL_DISPLAY_WIDTH - 1;
+    }
+
+    if(aXEnd >= LOCAL_DISPLAY_WIDTH) {
+        aXEnd = LOCAL_DISPLAY_WIDTH - 1;
+    }
+
+    if(aYStart >= LOCAL_DISPLAY_HEIGHT) {
+        aYStart = LOCAL_DISPLAY_HEIGHT - 1;
+    }
+
+    if(aYEnd >= LOCAL_DISPLAY_HEIGHT) {
+        aYEnd = LOCAL_DISPLAY_HEIGHT - 1;
+    }
+
+    if((aXStart == aXEnd) || (aYStart == aYEnd)) {
+        // horizontal or vertical line -> fillRect() is faster than drawLine()
+        // fillRect(
+        //     aXStart,
+        //     aYStart,
+        //     aXEnd,
+        //     aYEnd,
+        //     aColor); // you can remove the check and this line if you have no fillRect() or drawLine() available.
+        canvas_draw_box(canvas, aXStart, aYStart, aXEnd - aXStart, aYEnd - aYStart);
+    } else {
+        // calculate direction
+        tDeltaX = aXEnd - aXStart;
+        tDeltaY = aYEnd - aYStart;
+        if(tDeltaX < 0) {
+            tDeltaX = -tDeltaX;
+            tStepX = -1;
+        } else {
+            tStepX = +1;
+        }
+        if(tDeltaY < 0) {
+            tDeltaY = -tDeltaY;
+            tStepY = -1;
+        } else {
+            tStepY = +1;
+        }
+        tDeltaXTimes2 = tDeltaX << 1;
+        tDeltaYTimes2 = tDeltaY << 1;
+        // draw start pixel
+        // drawPixel(aXStart, aYStart, aColor);
+        canvas_draw_dot(canvas, aXStart, aYStart);
+        if(tDeltaX > tDeltaY) {
+            // start value represents a half step in Y direction
+            tError = tDeltaYTimes2 - tDeltaX;
+            while(aXStart != aXEnd) {
+                // step in main direction
+                aXStart += tStepX;
+                if(tError >= 0) {
+                    if(aOverlap & LINE_OVERLAP_MAJOR) {
+                        // draw pixel in main direction before changing
+                        // drawPixel(aXStart, aYStart, aColor);
+                        canvas_draw_dot(canvas, aXStart, aYStart);
+                    }
+                    // change Y
+                    aYStart += tStepY;
+                    if(aOverlap & LINE_OVERLAP_MINOR) {
+                        // draw pixel in minor direction before changing
+                        // drawPixel(aXStart - tStepX, aYStart, aColor);
+                        canvas_draw_dot(canvas, aXStart - tStepX, aYStart);
+                    }
+                    tError -= tDeltaXTimes2;
+                }
+                tError += tDeltaYTimes2;
+                // drawPixel(aXStart, aYStart, aColor);
+                canvas_draw_dot(canvas, aXStart, aYStart);
+            }
+        } else {
+            tError = tDeltaXTimes2 - tDeltaY;
+            while(aYStart != aYEnd) {
+                aYStart += tStepY;
+                if(tError >= 0) {
+                    if(aOverlap & LINE_OVERLAP_MAJOR) {
+                        // draw pixel in main direction before changing
+                        // drawPixel(aXStart, aYStart, aColor);
+                        canvas_draw_dot(canvas, aXStart, aYStart);
+                    }
+                    aXStart += tStepX;
+                    if(aOverlap & LINE_OVERLAP_MINOR) {
+                        // draw pixel in minor direction before changing
+                        // drawPixel(aXStart, aYStart - tStepY, aColor);
+                        canvas_draw_dot(canvas, aXStart, aYStart - tStepY);
+                    }
+                    tError -= tDeltaYTimes2;
+                }
+                tError += tDeltaXTimes2;
+                // drawPixel(aXStart, aYStart, aColor);
+                canvas_draw_dot(canvas, aXStart, aYStart);
+            }
+        }
+    }
+}
+
+/**
+ * Bresenham with thickness
+ * No pixel missed and every pixel only drawn once!
+ * The code is bigger and more complicated than drawThickLineSimple() but it tends to be faster, since drawing a pixel is often a slow operation.
+ * aThicknessMode can be one of LINE_THICKNESS_MIDDLE, LINE_THICKNESS_DRAW_CLOCKWISE, LINE_THICKNESS_DRAW_COUNTERCLOCKWISE
+ */
+void drawThickLine(
+    Canvas* canvas,
+    unsigned int aXStart,
+    unsigned int aYStart,
+    unsigned int aXEnd,
+    unsigned int aYEnd,
+    unsigned int aThickness,
+    uint8_t aThicknessMode) {
+    int16_t i, tDeltaX, tDeltaY, tDeltaXTimes2, tDeltaYTimes2, tError, tStepX, tStepY;
+
+    if(aThickness <= 1) {
+        drawLineOverlap(canvas, aXStart, aYStart, aXEnd, aYEnd, LINE_OVERLAP_NONE);
+    }
+    /*
+     * Clip to display size
+     */
+    if(aXStart >= LOCAL_DISPLAY_WIDTH) {
+        aXStart = LOCAL_DISPLAY_WIDTH - 1;
+    }
+
+    if(aXEnd >= LOCAL_DISPLAY_WIDTH) {
+        aXEnd = LOCAL_DISPLAY_WIDTH - 1;
+    }
+
+    if(aYStart >= LOCAL_DISPLAY_HEIGHT) {
+        aYStart = LOCAL_DISPLAY_HEIGHT - 1;
+    }
+
+    if(aYEnd >= LOCAL_DISPLAY_HEIGHT) {
+        aYEnd = LOCAL_DISPLAY_HEIGHT - 1;
+    }
+
+    /**
+     * For coordinate system with 0.0 top left
+     * Swap X and Y delta and calculate clockwise (new delta X inverted)
+     * or counterclockwise (new delta Y inverted) rectangular direction.
+     * The right rectangular direction for LINE_OVERLAP_MAJOR toggles with each octant
+     */
+    tDeltaY = aXEnd - aXStart;
+    tDeltaX = aYEnd - aYStart;
+    // mirror 4 quadrants to one and adjust deltas and stepping direction
+    bool tSwap = true; // count effective mirroring
+    if(tDeltaX < 0) {
+        tDeltaX = -tDeltaX;
+        tStepX = -1;
+        tSwap = !tSwap;
+    } else {
+        tStepX = +1;
+    }
+    if(tDeltaY < 0) {
+        tDeltaY = -tDeltaY;
+        tStepY = -1;
+        tSwap = !tSwap;
+    } else {
+        tStepY = +1;
+    }
+    tDeltaXTimes2 = tDeltaX << 1;
+    tDeltaYTimes2 = tDeltaY << 1;
+    bool tOverlap;
+    // adjust for right direction of thickness from line origin
+    int tDrawStartAdjustCount = aThickness / 2;
+    if(aThicknessMode == LINE_THICKNESS_DRAW_COUNTERCLOCKWISE) {
+        tDrawStartAdjustCount = aThickness - 1;
+    } else if(aThicknessMode == LINE_THICKNESS_DRAW_CLOCKWISE) {
+        tDrawStartAdjustCount = 0;
+    }
+
+    /*
+     * Now tDelta* are positive and tStep* define the direction
+     * tSwap is false if we mirrored only once
+     */
+    // which octant are we now
+    if(tDeltaX >= tDeltaY) {
+        // Octant 1, 3, 5, 7 (between 0 and 45, 90 and 135, ... degree)
+        if(tSwap) {
+            tDrawStartAdjustCount = (aThickness - 1) - tDrawStartAdjustCount;
+            tStepY = -tStepY;
+        } else {
+            tStepX = -tStepX;
+        }
+        /*
+         * Vector for draw direction of the starting points of lines is rectangular and counterclockwise to main line direction
+         * Therefore no pixel will be missed if LINE_OVERLAP_MAJOR is used on change in minor rectangular direction
+         */
+        // adjust draw start point
+        tError = tDeltaYTimes2 - tDeltaX;
+        for(i = tDrawStartAdjustCount; i > 0; i--) {
+            // change X (main direction here)
+            aXStart -= tStepX;
+            aXEnd -= tStepX;
+            if(tError >= 0) {
+                // change Y
+                aYStart -= tStepY;
+                aYEnd -= tStepY;
+                tError -= tDeltaXTimes2;
+            }
+            tError += tDeltaYTimes2;
+        }
+        // draw start line. We can alternatively use drawLineOverlap(aXStart, aYStart, aXEnd, aYEnd, LINE_OVERLAP_NONE, aColor) here.
+        // drawLine(aXStart, aYStart, aXEnd, aYEnd);
+        // canvas_draw_line(canvas, aXStart, aYStart, aXEnd, aYEnd);
+        drawLineOverlap(canvas, aXStart, aYStart, aXEnd, aYEnd, LINE_OVERLAP_NONE);
+        // draw aThickness number of lines
+        tError = tDeltaYTimes2 - tDeltaX;
+        for(i = aThickness; i > 1; i--) {
+            // change X (main direction here)
+            aXStart += tStepX;
+            aXEnd += tStepX;
+            tOverlap = LINE_OVERLAP_NONE;
+            if(tError >= 0) {
+                // change Y
+                aYStart += tStepY;
+                aYEnd += tStepY;
+                tError -= tDeltaXTimes2;
+                /*
+                 * Change minor direction reverse to line (main) direction
+                 * because of choosing the right (counter)clockwise draw vector
+                 * Use LINE_OVERLAP_MAJOR to fill all pixel
+                 *
+                 * EXAMPLE:
+                 * 1,2 = Pixel of first 2 lines
+                 * 3 = Pixel of third line in normal line mode
+                 * - = Pixel which will additionally be drawn in LINE_OVERLAP_MAJOR mode
+                 *           33
+                 *       3333-22
+                 *   3333-222211
+                 * 33-22221111
+                 *  221111                     ^
+                 *  11                          Main direction of start of lines draw vector
+                 *  -> Line main direction
+                 *  <- Minor direction of counterclockwise of start of lines draw vector
+                 */
+                tOverlap = LINE_OVERLAP_MAJOR;
+            }
+            tError += tDeltaYTimes2;
+            drawLineOverlap(canvas, aXStart, aYStart, aXEnd, aYEnd, tOverlap);
+        }
+    } else {
+        // the other octant 2, 4, 6, 8 (between 45 and 90, 135 and 180, ... degree)
+        if(tSwap) {
+            tStepX = -tStepX;
+        } else {
+            tDrawStartAdjustCount = (aThickness - 1) - tDrawStartAdjustCount;
+            tStepY = -tStepY;
+        }
+        // adjust draw start point
+        tError = tDeltaXTimes2 - tDeltaY;
+        for(i = tDrawStartAdjustCount; i > 0; i--) {
+            aYStart -= tStepY;
+            aYEnd -= tStepY;
+            if(tError >= 0) {
+                aXStart -= tStepX;
+                aXEnd -= tStepX;
+                tError -= tDeltaYTimes2;
+            }
+            tError += tDeltaXTimes2;
+        }
+        //draw start line
+        // drawLine(aXStart, aYStart, aXEnd, aYEnd);
+        canvas_draw_line(canvas, aXStart, aYStart, aXEnd, aYEnd);
+        // draw aThickness number of lines
+        tError = tDeltaXTimes2 - tDeltaY;
+        for(i = aThickness; i > 1; i--) {
+            aYStart += tStepY;
+            aYEnd += tStepY;
+            tOverlap = LINE_OVERLAP_NONE;
+            if(tError >= 0) {
+                aXStart += tStepX;
+                aXEnd += tStepX;
+                tError -= tDeltaYTimes2;
+                tOverlap = LINE_OVERLAP_MAJOR;
+            }
+            tError += tDeltaXTimes2;
+            drawLineOverlap(canvas, aXStart, aYStart, aXEnd, aYEnd, tOverlap);
+        }
+    }
+}
+/**
+ * The same as before, but no clipping to display range, some pixel are drawn twice (because of using LINE_OVERLAP_BOTH)
+ * and direction of thickness changes for each octant (except for LINE_THICKNESS_MIDDLE and aThickness value is odd)
+ * aThicknessMode can be LINE_THICKNESS_MIDDLE or any other value
+ *
+ */
+/*
+void drawThickLineSimple(
+    Canvas* canvas,
+    unsigned int aXStart,
+    unsigned int aYStart,
+    unsigned int aXEnd,
+    unsigned int aYEnd,
+    unsigned int aThickness,
+    uint8_t aThicknessMode) {
+    int16_t i, tDeltaX, tDeltaY, tDeltaXTimes2, tDeltaYTimes2, tError, tStepX, tStepY;
+
+    tDeltaY = aXStart - aXEnd;
+    tDeltaX = aYEnd - aYStart;
+    // mirror 4 quadrants to one and adjust deltas and stepping direction
+    if(tDeltaX < 0) {
+        tDeltaX = -tDeltaX;
+        tStepX = -1;
+    } else {
+        tStepX = +1;
+    }
+    if(tDeltaY < 0) {
+        tDeltaY = -tDeltaY;
+        tStepY = -1;
+    } else {
+        tStepY = +1;
+    }
+    tDeltaXTimes2 = tDeltaX << 1;
+    tDeltaYTimes2 = tDeltaY << 1;
+    bool tOverlap;
+    // which octant are we now
+    if(tDeltaX > tDeltaY) {
+        if(aThicknessMode == LINE_THICKNESS_MIDDLE) {
+            // adjust draw start point
+            tError = tDeltaYTimes2 - tDeltaX;
+            for(i = aThickness / 2; i > 0; i--) {
+                // change X (main direction here)
+                aXStart -= tStepX;
+                aXEnd -= tStepX;
+                if(tError >= 0) {
+                    // change Y
+                    aYStart -= tStepY;
+                    aYEnd -= tStepY;
+                    tError -= tDeltaXTimes2;
+                }
+                tError += tDeltaYTimes2;
+            }
+        }
+        // draw start line. We can alternatively use drawLineOverlap(aXStart, aYStart, aXEnd, aYEnd, LINE_OVERLAP_NONE, aColor) here.
+        // drawLine(aXStart, aYStart, aXEnd, aYEnd, aColor);
+        canvas_draw_line(canvas, aXStart, aYStart, aXEnd, aYEnd);
+        // draw aThickness lines
+        tError = tDeltaYTimes2 - tDeltaX;
+        for(i = aThickness; i > 1; i--) {
+            // change X (main direction here)
+            aXStart += tStepX;
+            aXEnd += tStepX;
+            tOverlap = LINE_OVERLAP_NONE;
+            if(tError >= 0) {
+                // change Y
+                aYStart += tStepY;
+                aYEnd += tStepY;
+                tError -= tDeltaXTimes2;
+                tOverlap = LINE_OVERLAP_BOTH;
+            }
+            tError += tDeltaYTimes2;
+            drawLineOverlap(canvas, aXStart, aYStart, aXEnd, aYEnd, tOverlap);
+        }
+    } else {
+        // adjust draw start point
+        if(aThicknessMode == LINE_THICKNESS_MIDDLE) {
+            tError = tDeltaXTimes2 - tDeltaY;
+            for(i = aThickness / 2; i > 0; i--) {
+                aYStart -= tStepY;
+                aYEnd -= tStepY;
+                if(tError >= 0) {
+                    aXStart -= tStepX;
+                    aXEnd -= tStepX;
+                    tError -= tDeltaYTimes2;
+                }
+                tError += tDeltaXTimes2;
+            }
+        }
+        // draw start line. We can alternatively use drawLineOverlap(aXStart, aYStart, aXEnd, aYEnd, LINE_OVERLAP_NONE, aColor) here.
+        // drawLine(aXStart, aYStart, aXEnd, aYEnd, aColor);
+        canvas_draw_line(canvas, aXStart, aYStart, aXEnd, aYEnd);
+        tError = tDeltaXTimes2 - tDeltaY;
+        for(i = aThickness; i > 1; i--) {
+            aYStart += tStepY;
+            aYEnd += tStepY;
+            tOverlap = LINE_OVERLAP_NONE;
+            if(tError >= 0) {
+                aXStart += tStepX;
+                aXEnd += tStepX;
+                tError -= tDeltaYTimes2;
+                tOverlap = LINE_OVERLAP_BOTH;
+            }
+            tError += tDeltaXTimes2;
+            drawLineOverlap(canvas, aXStart, aYStart, aXEnd, aYEnd, tOverlap);
+        }
+    }
+}
+*/
+
+}; // namespace
+
+/*
+  Fontname: micro
+  Copyright: Public domain font.  Share and enjoy.
+  Glyphs: 18/128
+  BBX Build Mode: 0
+*/
+const uint8_t u8g2_font_micro_tn[148] =
+    "\22\0\2\3\2\3\1\4\4\3\5\0\0\5\0\5\0\0\0\0\0\0w \4`\63*\10\67\62Q"
+    "j\312\0+\7or\321\24\1,\5*r\3-\5\247\62\3.\5*\62\4/\10\67\262\251\60\12"
+    "\1\60\10\67r)U\12\0\61\6\66rS\6\62\7\67\62r\224\34\63\7\67\62r$\22\64\7\67"
+    "\62\221\212\14\65\7\67\62\244<\1\66\6\67r#E\67\10\67\62c*\214\0\70\6\67\62TE\71"
+    "\7\67\62\24\71\1:\6\66\62$\1\0\0\0\4\377\377\0";
+
+// TODO: allow points to be located outside the canvas. currently, the canvas_* methods
+// choke on this in some cases, resulting in large vertical/horizontal lines
+void gfx_draw_line(Canvas* canvas, float x1, float y1, float x2, float y2) {
+    canvas_draw_line(
+        canvas, roundf(x1 / SCALE), roundf(y1 / SCALE), roundf(x2 / SCALE), roundf(y2 / SCALE));
+}
+
+void gfx_draw_line(Canvas* canvas, const Vec2& p1, const Vec2& p2) {
+    gfx_draw_line(canvas, p1.x, p1.y, p2.x, p2.y);
+}
+
+void gfx_draw_line_thick(Canvas* canvas, float x1, float y1, float x2, float y2, int thickness) {
+    x1 = roundf(x1 / SCALE);
+    y1 = roundf(y1 / SCALE);
+    x2 = roundf(x2 / SCALE);
+    y2 = roundf(y2 / SCALE);
+
+    drawThickLine(canvas, x1, y1, x2, y2, thickness, LINE_THICKNESS_MIDDLE);
+}
+
+void gfx_draw_line_thick(Canvas* canvas, const Vec2& p1, const Vec2& p2, int thickness) {
+    gfx_draw_line_thick(canvas, p1.x, p1.y, p2.x, p2.y, thickness);
+}
+
+void gfx_draw_disc(Canvas* canvas, float x, float y, float r) {
+    canvas_draw_disc(canvas, roundf(x / SCALE), roundf(y / SCALE), roundf(r / SCALE));
+}
+void gfx_draw_disc(Canvas* canvas, const Vec2& p, float r) {
+    gfx_draw_disc(canvas, p.x, p.y, r);
+}
+
+void gfx_draw_circle(Canvas* canvas, float x, float y, float r) {
+    canvas_draw_circle(canvas, roundf(x / SCALE), roundf(y / SCALE), roundf(r / SCALE));
+}
+void gfx_draw_circle(Canvas* canvas, const Vec2& p, float r) {
+    gfx_draw_circle(canvas, p.x, p.y, r);
+}
+
+void gfx_draw_dot(Canvas* canvas, float x, float y) {
+    canvas_draw_dot(canvas, roundf(x / SCALE), roundf(y / SCALE));
+}
+void gfx_draw_dot(Canvas* canvas, const Vec2& p) {
+    gfx_draw_dot(canvas, p.x, p.y);
+}
+
+void gfx_draw_arc(Canvas* canvas, const Vec2& p, float r, float start, float end) {
+    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)));
+        gfx_draw_line(canvas, sx, sy, nx, ny);
+        sx = nx;
+        sy = ny;
+    }
+}
+
+void gfx_draw_str(Canvas* canvas, int x, int y, Align h, Align v, const char* str) {
+    canvas_set_custom_u8g2_font(canvas, u8g2_font_micro_tn);
+
+    canvas_set_color(canvas, ColorWhite);
+    int w = canvas_string_width(canvas, str);
+    if(h == AlignRight) {
+        canvas_draw_box(canvas, x - 1 - w, y, w + 2, 6);
+    } else if(h == AlignLeft) {
+        canvas_draw_box(canvas, x - 1, y, w + 2, 6);
+    }
+
+    canvas_set_color(canvas, ColorBlack);
+    canvas_draw_str_aligned(canvas, x, y, h, v, str);
+
+    canvas_set_font(canvas, FontSecondary); // reset?
+}

+ 29 - 0
pinball0/graphics.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <gui/gui.h>
+#include "vec2.h"
+
+// Use to draw table elements, which live on a 640 x 1280 grid
+// These methods will scale and round the coordinates
+// Also, they will (eventually) handle cases where the thing we're drawing
+// lies outside the table bounds.
+
+void gfx_draw_line(Canvas* canvas, float x1, float y1, float x2, float y2);
+void gfx_draw_line(Canvas* canvas, const Vec2& p1, const Vec2& p2);
+
+void gfx_draw_line_thick(Canvas* canvas, float x1, float y1, float x2, float y2, int thickness);
+void gfx_draw_line_thick(Canvas* canvas, const Vec2& p1, const Vec2& p2, int thickness);
+
+void gfx_draw_disc(Canvas* canvas, float x, float y, float r);
+void gfx_draw_disc(Canvas* canvas, const Vec2& p, float r);
+
+void gfx_draw_circle(Canvas* canvas, float x, float y, float r);
+void gfx_draw_circle(Canvas* canvas, const Vec2& p, float r);
+
+void gfx_draw_dot(Canvas* canvas, float x, float y);
+void gfx_draw_dot(Canvas* canvas, const Vec2& p);
+
+void gfx_draw_arc(Canvas* canvas, const Vec2& p, float r, float start, float end);
+
+// Uses the micro font
+void gfx_draw_str(Canvas* canvas, int x, int y, Align h, Align v, const char* str);

+ 0 - 0
pinball0/images/.gitkeep


BIN
pinball0/images/Arcade_A.png


BIN
pinball0/images/Arcade_E.png


BIN
pinball0/images/Arcade_G.png


BIN
pinball0/images/Arcade_I.png


BIN
pinball0/images/Arcade_L.png


BIN
pinball0/images/Arcade_M.png


BIN
pinball0/images/Arcade_O.png


BIN
pinball0/images/Arcade_R.png


BIN
pinball0/images/Arcade_T.png


BIN
pinball0/images/Arcade_V.png


BIN
pinball0/images/pinball0_logo.png


+ 276 - 0
pinball0/notifications.cxx

@@ -0,0 +1,276 @@
+#include "notifications.h"
+#include "pinball0.h"
+
+static const NotificationMessage* nm_list[32];
+
+// static FuriMutex* nm_mutex;
+
+// void notify_init() {
+//     nm_mutex = furi_mutex_alloc(FuriMutexTypeNormal);
+// }
+// void notify_free() {
+//     furi_mutex_free(nm_mutex);
+// }
+
+void notify_ball_released(void* ctx) {
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+    if(app->settings.vibrate_enabled) {
+        nm_list[n++] = &message_vibro_on;
+    }
+    nm_list[n++] = &message_delay_100;
+    if(app->settings.vibrate_enabled) {
+        nm_list[n++] = &message_vibro_off;
+    }
+    nm_list[n] = NULL;
+    notification_message(app->notify, &nm_list);
+}
+
+void notify_table_bump(void* ctx) {
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+    if(app->settings.vibrate_enabled) {
+        nm_list[n++] = &message_vibro_on;
+    }
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_red_255;
+    }
+    nm_list[n++] = &message_delay_100;
+    if(app->settings.vibrate_enabled) {
+        nm_list[n++] = &message_vibro_off;
+    }
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_red_0;
+    }
+    nm_list[n] = NULL;
+    notification_message(app->notify, &nm_list);
+}
+
+void notify_table_tilted(void* ctx) {
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+
+    for(int i = 0; i < 2; i++) {
+        nm_list[n++] = &message_display_backlight_off;
+        if(app->settings.vibrate_enabled) {
+            nm_list[n++] = &message_vibro_on;
+        }
+        if(app->settings.led_enabled) {
+            nm_list[n++] = &message_red_255;
+        }
+        nm_list[n++] = &message_delay_500;
+
+        nm_list[n++] = &message_display_backlight_on;
+        if(app->settings.vibrate_enabled) {
+            nm_list[n++] = &message_vibro_off;
+        }
+        if(app->settings.led_enabled) {
+            nm_list[n++] = &message_red_0;
+        }
+    }
+    nm_list[n] = NULL;
+    notification_message(app->notify, &nm_list);
+}
+
+void notify_error_message(void* ctx) {
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_note_c6;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_sound_off;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_c5;
+        nm_list[n++] = &message_delay_250;
+        nm_list[n++] = &message_sound_off;
+    }
+    nm_list[n] = NULL;
+    notification_message(app->notify, &nm_list);
+}
+
+void notify_game_over(void* ctx) {
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_delay_500;
+        nm_list[n++] = &message_note_b5;
+        nm_list[n++] = &message_delay_250;
+        nm_list[n++] = &message_note_f6;
+        nm_list[n++] = &message_delay_250;
+        nm_list[n++] = &message_sound_off;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_f6;
+        nm_list[n++] = &message_delay_100;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_f6;
+        nm_list[n++] = &message_delay_100;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_e6;
+        nm_list[n++] = &message_delay_100;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_d6;
+        nm_list[n++] = &message_delay_100;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_c6;
+        nm_list[n++] = &message_delay_1000;
+        nm_list[n++] = &message_sound_off;
+    }
+    nm_list[n] = NULL;
+    notification_message(app->notify, &nm_list);
+}
+
+void notify_bumper_hit(void* ctx) {
+    FURI_LOG_I(TAG, "notify_bumper_hit");
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_blue_255;
+    }
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_note_f4;
+        nm_list[n++] = &message_delay_10;
+        nm_list[n++] = &message_note_f5;
+        nm_list[n++] = &message_delay_10;
+        nm_list[n++] = &message_sound_off;
+    }
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_blue_0;
+    }
+    nm_list[n] = NULL;
+    notification_message(app->notify, &nm_list);
+}
+
+void notify_rail_hit(void* ctx) {
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_note_d4;
+        nm_list[n++] = &message_delay_10;
+        nm_list[n++] = &message_note_d5;
+        nm_list[n++] = &message_delay_10;
+        nm_list[n++] = &message_sound_off;
+    }
+    nm_list[n] = NULL;
+    notification_message(app->notify, &nm_list);
+}
+
+void notify_portal(void* ctx) {
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_blue_255;
+        nm_list[n++] = &message_red_255;
+    }
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_note_c4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_e4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_b4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_c5;
+        nm_list[n++] = &message_delay_50;
+    }
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_blue_255;
+        nm_list[n++] = &message_red_0;
+    }
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_note_e4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_g4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_c5;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_e5;
+        nm_list[n++] = &message_delay_50;
+
+        nm_list[n++] = &message_sound_off;
+    }
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_blue_0;
+    }
+    nm_list[n] = NULL;
+    notification_message(app->notify, &nm_list);
+}
+
+void notify_lost_life(void* ctx) {
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_red_255;
+        nm_list[n++] = &message_green_255;
+    }
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_note_c5;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_c4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_b4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_b3;
+        nm_list[n++] = &message_delay_50;
+    }
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_green_0;
+    }
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_note_as4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_as3;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_a4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_a3;
+        nm_list[n++] = &message_delay_50;
+    }
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_green_255;
+    }
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_note_gs4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_gs3;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_g4;
+        nm_list[n++] = &message_delay_50;
+        nm_list[n++] = &message_note_g4;
+        nm_list[n++] = &message_delay_50;
+
+        nm_list[n++] = &message_sound_off;
+    }
+    if(app->settings.led_enabled) {
+        nm_list[n++] = &message_red_0;
+        nm_list[n++] = &message_green_0;
+    }
+    furi_assert(n < 32);
+    nm_list[n] = NULL;
+    notification_message_block(app->notify, &nm_list);
+}
+
+void notify_flipper(void* ctx) {
+    PinballApp* app = (PinballApp*)ctx;
+    int n = 0;
+    if(app->settings.sound_enabled) {
+        nm_list[n++] = &message_note_c4;
+        nm_list[n++] = &message_delay_10;
+        nm_list[n++] = &message_note_cs4;
+        nm_list[n++] = &message_delay_10;
+        nm_list[n++] = &message_sound_off;
+    }
+    nm_list[n] = NULL;
+    notification_message(app->notify, &nm_list);
+}
+
+/*
+Mario coin sound - ish
+        nm_list[n++] = &message_note_b5;
+        nm_list[n++] = &message_delay_10;
+        nm_list[n++] = &message_delay_10;
+        nm_list[n++] = &message_delay_10;
+        nm_list[n++] = &message_sound_off;
+        nm_list[n++] = &message_note_e6;
+        nm_list[n++] = &message_delay_250;
+        nm_list[n++] = &message_delay_100;
+
+*/

+ 22 - 0
pinball0/notifications.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include <furi.h>
+#include <notification/notification.h>
+
+// void notify_init();
+// void notify_free();
+
+void notify_ball_released(void* ctx);
+void notify_table_bump(void* ctx);
+void notify_table_tilted(void* ctx);
+
+void notify_error_message(void* ctx);
+void notify_game_over(void* ctx);
+
+void notify_bumper_hit(void* ctx);
+void notify_rail_hit(void* ctx);
+
+void notify_portal(void* ctx);
+void notify_lost_life(void* ctx);
+
+void notify_flipper(void* ctx);

+ 142 - 0
pinball0/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
pinball0/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
pinball0/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 */

+ 670 - 0
pinball0/objects.cxx

@@ -0,0 +1,670 @@
+#include <furi.h>
+#include <gui/gui.h>
+
+#include "objects.h"
+#include "pinball0.h"
+#include "graphics.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) {
+    gfx_draw_disc(canvas, p, r);
+}
+
+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)
+    , score(50)
+    , notification(nullptr) {
+    if(side_ == Side::LEFT) {
+        rest_angle = -0.4f;
+        sign = 1;
+    } else {
+        rest_angle = M_PI + 0.4;
+        sign = -1;
+    }
+}
+
+void Flipper::draw(Canvas* canvas) {
+    // tip
+    float angle = rest_angle + sign * rotation;
+    Vec2 dir(cos(angle), -sin(angle));
+
+    // draw the tip
+    Vec2 tip = p + dir * size;
+    gfx_draw_line_thick(canvas, p, tip, (r * 1.5f) / 10.0f);
+    gfx_draw_disc(canvas, tip, r * 0.6f);
+
+    // // base / pivot
+    // gfx_draw_circle(canvas, p, r);
+
+    // // tip
+    // float angle = rest_angle + sign * rotation;
+    // Vec2 dir(cos(angle), -sin(angle));
+
+    // // draw the tip
+    // Vec2 tip = p + dir * size;
+    // gfx_draw_circle(canvas, tip, r);
+
+    // // top and bottom lines
+    // Vec2 perp(-dir.y, dir.x);
+    // perp.normalize();
+    // Vec2 start = p + perp * r;
+    // Vec2 end = start + dir * size;
+    // gfx_draw_line(canvas, start, end);
+
+    // perp *= -1.0f;
+    // start = p + perp * r;
+    // end = start + dir * size;
+    // gfx_draw_line(canvas, start, end);
+}
+
+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++) {
+            gfx_draw_line(canvas, points[i], points[i + 1]);
+#ifdef DRAW_NORMALS
+            Vec2 c = (points[i] + points[i + 1]) / 2.0f;
+            Vec2 e = c + normals[i] * 40.0f;
+            gfX_draw_line(canvas, c, e);
+#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(ball_v.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
+        gfx_draw_line(canvas, a1, a2);
+        d = a1 + au * amag * 0.33f;
+        e = d + na * 20.0f;
+        gfx_draw_line(canvas, d, e);
+        d += au * amag * 0.33f;
+        e = d + na * 20.0f;
+        gfx_draw_line(canvas, d, e);
+
+        // Portal B
+        gfx_draw_line(canvas, b1, b2);
+        d = b1 + bu * bmag * 0.33f;
+        e = d + nb * 20.0f;
+        gfx_draw_line(canvas, d, e);
+        d += bu * bmag * 0.33f;
+        e = d + nb * 20.0f;
+        gfx_draw_line(canvas, d, e);
+
+        if(decay > 0) {
+            gfx_draw_circle(canvas, enter_p, 20);
+        }
+    }
+#ifdef DRAW_NORMALS
+    Vec2 c = (a1 + a2) / 2.0f;
+    Vec2 e = c + na * 40.0f;
+    gfx_draw_line(canvas, c, e);
+    c = (b1 + b2) / 2.0f;
+    e = c + nb * 40.0f;
+    gfx_draw_line(canvas, c, e);
+#endif
+}
+
+// TODO: simplify this code?
+bool Portal::collide(Ball& ball) {
+    Vec2 ball_v = ball.p - ball.prev_p;
+    float dist;
+
+    Vec2 a_cl = Vec2_closest(a1, a2, ball.p);
+    dist = (ball.p - a_cl).mag();
+    if(dist <= ball.r && ball_v.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);
+    dist = (ball.p - b_cl).mag();
+    if(dist <= ball.r && ball_v.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) {
+        gfx_draw_circle(canvas, p, r);
+    } 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)));
+            gfx_draw_line(canvas, sx, sy, nx, ny);
+            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_) {
+    score = 500;
+}
+
+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));
+        gfx_draw_disc(canvas, p, r * 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 {
+        gfx_draw_dot(canvas, p);
+    }
+}
+
+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) {
+    gfx_draw_line(canvas, chevron_1[0], chevron_1[1]);
+    gfx_draw_line(canvas, chevron_1[1], chevron_1[2]);
+
+    gfx_draw_line(canvas, chevron_2[0], chevron_2[1]);
+    gfx_draw_line(canvas, chevron_2[1], chevron_2[2]);
+}
+
+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;
+    }
+}

+ 295 - 0
pinball0/objects.h

@@ -0,0 +1,295 @@
+#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_ = Vec2(), 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?
+
+    int score;
+    void (*notification)(void* app);
+};
+
+// A static object that never moves and can be any shape
+class FixedObject {
+public:
+    FixedObject()
+        : bounce(1.0f)
+        , physical(true)
+        , hidden(false)
+        , score(0)
+        , notification(nullptr) {
+    }
+    virtual ~FixedObject() = default;
+
+    float bounce;
+    bool physical; // can be hit
+    bool hidden; // do not draw
+    int score;
+
+    void (*notification)(void* app);
+
+    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_) {
+        score = 200;
+    }
+    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';
+        score = 400;
+    }
+
+    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;
+// };

+ 661 - 0
pinball0/pinball0.cxx

@@ -0,0 +1,661 @@
+#include <furi.h>
+
+#include <notification/notification.h>
+#include <cstring>
+#include "pinball0.h"
+#include "table.h"
+#include "notifications.h"
+#include "settings.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 MANUAL_ADJUSTMENT 20
+#define IDLE_TIMEOUT      120 * 1000 // 120 seconds * 1000 ticks/sec
+#define BUMP_DELAY        2 * 1000 // 2 seconds
+#define BUMP_MAX          3
+
+void solve(PinballApp* 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)) {
+                    if(pb->game_mode == GM_Tilted) {
+                        continue;
+                    }
+                    if(o->notification) {
+                        (*o->notification)(pb);
+                    }
+                    table->score.value += o->score;
+                    o->reset_animation();
+                    continue;
+                }
+            }
+            for(auto& f : table->flippers) {
+                if(f.collide(b)) {
+                    if(pb->game_mode == GM_Tilted) {
+                        continue;
+                    }
+                    if(f.notification) {
+                        (*f.notification)(pb);
+                    }
+                    table->score.value += f.score;
+                    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?
+    if(table->balls.size()) {
+        auto 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--;
+                notify_lost_life(pb);
+            } else {
+                ++i;
+            }
+        }
+        if(num_in_play == 0) {
+            table->balls_released = false;
+            table->lives.value--;
+            if(table->lives.value > 0) {
+                // Reset our ball to it's starting position
+                table->balls = table->balls_initial;
+                if(pb->game_mode == GM_Tilted) {
+                    pb->game_mode = GM_Playing;
+                }
+            } else {
+                table->game_over = true;
+            }
+        }
+    }
+}
+
+static void pinball_draw_callback(Canvas* const canvas, void* ctx) {
+    furi_assert(ctx);
+    PinballApp* pb = (PinballApp*)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;
+        auto half_way = list.display_size / 2;
+
+        for(auto 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);
+
+        const int32_t y = 56;
+        const size_t interval = 40;
+        const float theta = (float)((pb->tick % interval) / (interval * 1.0f)) * (float)(M_PI * 2);
+        const float sin_theta_4 = sinf(theta) * 4;
+
+        const int border = 3;
+        canvas_set_color(canvas, ColorWhite);
+        canvas_draw_box(
+            canvas, 16 - border, y + sin_theta_4 - border, 32 + border * 2, 16 + border * 2);
+        canvas_set_color(canvas, ColorBlack);
+
+        canvas_draw_icon(canvas, 16, y + sin_theta_4, &I_Arcade_G);
+        canvas_draw_icon(canvas, 24, y + sin_theta_4, &I_Arcade_A);
+        canvas_draw_icon(canvas, 32, y + sin_theta_4, &I_Arcade_M);
+        canvas_draw_icon(canvas, 40, y + sin_theta_4, &I_Arcade_E);
+
+        canvas_draw_icon(canvas, 16, y + sin_theta_4 + 8, &I_Arcade_O);
+        canvas_draw_icon(canvas, 24, y + sin_theta_4 + 8, &I_Arcade_V);
+        canvas_draw_icon(canvas, 32, y + sin_theta_4 + 8, &I_Arcade_E);
+        canvas_draw_icon(canvas, 40, y + sin_theta_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;
+    case GM_Settings: {
+        // TODO: like... do better here. maybe vector of settings strings, etc
+        canvas_draw_str_aligned(canvas, 2, 10, AlignLeft, AlignTop, "SETTINGS");
+
+        int x = 55;
+        int y = 30;
+
+        canvas_draw_str_aligned(canvas, 10, y, AlignLeft, AlignTop, "Sound");
+        canvas_draw_circle(canvas, x, y + 3, 4);
+        if(pb->settings.sound_enabled) {
+            canvas_draw_disc(canvas, x, y + 3, 2);
+        }
+        if(pb->settings.selected_setting == 0) {
+            canvas_draw_triangle(canvas, 2, y + 3, 8, 5, CanvasDirectionLeftToRight);
+        }
+        y += 12;
+
+        canvas_draw_str_aligned(canvas, 10, y, AlignLeft, AlignTop, "LED");
+        canvas_draw_circle(canvas, x, y + 3, 4);
+        if(pb->settings.led_enabled) {
+            canvas_draw_disc(canvas, x, y + 3, 2);
+        }
+        if(pb->settings.selected_setting == 1) {
+            canvas_draw_triangle(canvas, 2, y + 3, 8, 5, CanvasDirectionLeftToRight);
+        }
+        y += 12;
+
+        canvas_draw_str_aligned(canvas, 10, y, AlignLeft, AlignTop, "Vibrate");
+        canvas_draw_circle(canvas, x, y + 3, 4);
+        if(pb->settings.vibrate_enabled) {
+            canvas_draw_disc(canvas, x, y + 3, 2);
+        }
+        if(pb->settings.selected_setting == 2) {
+            canvas_draw_triangle(canvas, 2, y + 3, 8, 5, CanvasDirectionLeftToRight);
+        }
+        y += 12;
+
+        canvas_draw_str_aligned(canvas, 10, y, AlignLeft, AlignTop, "Debug");
+        canvas_draw_circle(canvas, x, y + 3, 4);
+        if(pb->settings.debug_mode) {
+            canvas_draw_disc(canvas, x, y + 3, 2);
+        }
+        if(pb->settings.selected_setting == 3) {
+            canvas_draw_triangle(canvas, 2, y + 3, 8, 5, CanvasDirectionLeftToRight);
+        }
+
+        // About information
+        canvas_draw_str_aligned(canvas, 2, 88, AlignLeft, AlignTop, "Pinball0 " VERSION);
+        canvas_draw_str_aligned(canvas, 2, 98, AlignLeft, AlignTop, "github.com/");
+        canvas_draw_str_aligned(canvas, 2, 108, AlignLeft, AlignTop, "  rdefeo/");
+        canvas_draw_str_aligned(canvas, 2, 118, AlignLeft, AlignTop, "    pinball0");
+
+        pb->table->draw(canvas);
+    } break;
+    case GM_Tilted: {
+        pb->table->draw(canvas);
+
+        const int32_t y = 56;
+        const int border = 8;
+        canvas_set_color(canvas, ColorWhite);
+        canvas_draw_box(canvas, 16 - border, y - border, 32 + border * 2, 8 + border * 2);
+        canvas_set_color(canvas, ColorBlack);
+
+        bool display = furi_get_tick() % 1000 < 500;
+        if(display) {
+            canvas_draw_icon(canvas, 17, y, &I_Arcade_T);
+            canvas_draw_icon(canvas, 25, y, &I_Arcade_I);
+            canvas_draw_icon(canvas, 33, y, &I_Arcade_L);
+            canvas_draw_icon(canvas, 40, y, &I_Arcade_T);
+        }
+
+        int dots = 5;
+        int x_start = 16;
+        int x_gap = (48 - 16) / (dots - 1);
+        for(int x = 0; x < 5; x++, x_start += x_gap) {
+            if(x % 2 != display) {
+                canvas_draw_disc(canvas, x_start, 50, 2);
+                canvas_draw_disc(canvas, x_start, 70, 2);
+            } else {
+                canvas_draw_dot(canvas, x_start, 50);
+                canvas_draw_dot(canvas, x_start, 70);
+            }
+        }
+
+    } 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, input_event, FuriWaitForever);
+}
+
+PinballApp::PinballApp() {
+    initialized = false;
+
+    mutex = furi_mutex_alloc(FuriMutexTypeNormal);
+    if(!mutex) {
+        FURI_LOG_E(TAG, "Cannot create mutex!");
+        return;
+    }
+
+    storage = (Storage*)furi_record_open(RECORD_STORAGE);
+    notify = (NotificationApp*)furi_record_open(RECORD_NOTIFICATION);
+    // notify_init();
+    notification_message(notify, &sequence_display_backlight_enforce_on);
+
+    table = NULL;
+    tick = 0;
+
+    game_mode = GM_TableSelect;
+    keys[InputKeyUp] = false;
+    keys[InputKeyDown] = false;
+    keys[InputKeyRight] = false;
+    keys[InputKeyLeft] = false;
+
+    initialized = true;
+}
+
+PinballApp::~PinballApp() {
+    furi_mutex_free(mutex);
+    delete table;
+    // notify_free();
+
+    notification_message(notify, &sequence_display_backlight_enforce_auto);
+    notification_message(notify, &sequence_reset_rgb);
+
+    furi_record_close(RECORD_STORAGE);
+    furi_record_close(RECORD_NOTIFICATION);
+}
+
+extern "C" int32_t pinball0_app(void* p) {
+    UNUSED(p);
+
+    PinballApp app;
+    if(!app.initialized) {
+        FURI_LOG_E(TAG, "Failed to initialize Pinball0! Exiting.");
+        return 0;
+    }
+
+    pinball_load_settings(app);
+
+    // read the list of tables from storage
+    table_table_list_init(&app);
+
+    table_load_table(&app, TABLE_SELECT);
+
+    FuriMessageQueue* event_queue = furi_message_queue_alloc(8, sizeof(InputEvent));
+    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, &app);
+    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);
+
+    // TODO: Dolphin deed actions
+    // dolphin_deed(DolphinDeedPluginGameStart);
+
+    app.processing = true;
+
+    float dt = 0.0f;
+    uint32_t last_frame_time = furi_get_tick();
+    app.idle_start = last_frame_time;
+
+    // I'm not thrilled with this event loop - kinda messy but it'll do for now
+    InputEvent event;
+    while(app.processing) {
+        FuriStatus event_status = furi_message_queue_get(event_queue, &event, 10);
+        furi_mutex_acquire(app.mutex, FuriWaitForever);
+
+        if(event_status == FuriStatusOk) {
+            if(event.type == InputTypePress || event.type == InputTypeLong ||
+               event.type == InputTypeRepeat) {
+                switch(event.key) {
+                case InputKeyBack: // navigate to previous screen or exit
+                    switch(app.game_mode) {
+                    case GM_TableSelect:
+                        app.processing = false;
+                        break;
+                    case GM_Settings:
+                        pinball_save_settings(app);
+                        // fall through
+                    default:
+                        app.game_mode = GM_TableSelect;
+                        table_load_table(&app, TABLE_SELECT);
+                        break;
+                    }
+                    break;
+                case InputKeyRight: {
+                    if(app.game_mode == GM_Tilted) {
+                        break;
+                    }
+
+                    app.keys[InputKeyRight] = true;
+
+                    if(app.settings.debug_mode && app.table->balls_released == false) {
+                        app.table->balls[0].p.x += MANUAL_ADJUSTMENT;
+                        app.table->balls[0].prev_p.x += MANUAL_ADJUSTMENT;
+                    }
+                    bool flipper_pressed = false;
+                    for(auto& f : app.table->flippers) {
+                        if(f.side == Flipper::RIGHT) {
+                            f.powered = true;
+                            if(f.rotation != f.max_rotation) {
+                                flipper_pressed = true;
+                            }
+                        }
+                    }
+                    if(flipper_pressed) {
+                        notify_flipper(&app);
+                    }
+                } break;
+                case InputKeyLeft: {
+                    if(app.game_mode == GM_Tilted) {
+                        break;
+                    }
+
+                    app.keys[InputKeyLeft] = true;
+
+                    if(app.settings.debug_mode && app.table->balls_released == false) {
+                        app.table->balls[0].p.x -= MANUAL_ADJUSTMENT;
+                        app.table->balls[0].prev_p.x -= MANUAL_ADJUSTMENT;
+                    }
+                    bool flipper_pressed = false;
+                    for(auto& f : app.table->flippers) {
+                        if(f.side == Flipper::LEFT) {
+                            f.powered = true;
+                            if(f.rotation != f.max_rotation) {
+                                flipper_pressed = true;
+                            }
+                        }
+                    }
+                    if(flipper_pressed) {
+                        notify_flipper(&app);
+                    }
+                } break;
+                case InputKeyUp:
+                    switch(app.game_mode) {
+                    case GM_Playing:
+                        if(event.type == InputTypePress) {
+                            // Table bump and Tilt tracking
+                            uint32_t current_tick = furi_get_tick();
+                            if(current_tick - app.table->last_bump >= BUMP_DELAY) {
+                                app.table->bump_count++;
+                                app.table->last_bump = current_tick;
+                                if(!app.table->tilt_detect_enabled ||
+                                   app.table->bump_count < BUMP_MAX) {
+                                    app.keys[InputKeyUp] = true;
+                                    notify_table_bump(&app);
+                                } else {
+                                    FURI_LOG_W(TAG, "TABLE TILTED!");
+                                    app.game_mode = GM_Tilted;
+                                    app.table->bump_count = 0;
+                                    notify_table_tilted(&app);
+                                }
+                            }
+                        }
+                        if(app.settings.debug_mode && app.table->balls_released == false) {
+                            app.table->balls[0].p.y -= MANUAL_ADJUSTMENT;
+                            app.table->balls[0].prev_p.y -= MANUAL_ADJUSTMENT;
+                        }
+                        break;
+                    case GM_TableSelect:
+                        app.table_list.selected =
+                            (app.table_list.selected - 1 + app.table_list.menu_items.size()) %
+                            app.table_list.menu_items.size();
+                        break;
+                    case GM_Settings:
+                        if(app.settings.selected_setting > 0) {
+                            app.settings.selected_setting--;
+                        }
+                        break;
+                    default:
+                        FURI_LOG_W(TAG, "Table tilted, UP does nothing!");
+                        break;
+                    }
+                    break;
+                case InputKeyDown:
+                    switch(app.game_mode) {
+                    case GM_Playing:
+                        app.keys[InputKeyDown] = true;
+                        if(app.settings.debug_mode && app.table->balls_released == false) {
+                            app.table->balls[0].p.y += MANUAL_ADJUSTMENT;
+                            app.table->balls[0].prev_p.y += MANUAL_ADJUSTMENT;
+                        }
+                        break;
+                    case GM_TableSelect:
+                        app.table_list.selected =
+                            (app.table_list.selected + 1 + app.table_list.menu_items.size()) %
+                            app.table_list.menu_items.size();
+                        break;
+                    case GM_Settings:
+                        if(app.settings.selected_setting < app.settings.max_settings - 1) {
+                            app.settings.selected_setting++;
+                        }
+                        break;
+                    default:
+                        break;
+                    }
+                    break;
+                case InputKeyOk:
+                    switch(app.game_mode) {
+                    case GM_Playing:
+                        if(!app.table->balls_released) {
+                            app.table->balls_released = true;
+                            notify_ball_released(&app);
+                        }
+                        break;
+                    case GM_TableSelect: {
+                        size_t sel = app.table_list.selected;
+                        if(sel == app.table_list.menu_items.size() - 1) {
+                            app.game_mode = GM_Settings;
+                            table_load_table(&app, TABLE_SETTINGS);
+                        } else if(!table_load_table(&app, sel + TABLE_INDEX_OFFSET)) {
+                            app.game_mode = GM_Error;
+                            table_load_table(&app, TABLE_ERROR);
+                            notify_error_message(&app);
+                        } else {
+                            app.game_mode = GM_Playing;
+                        }
+                    } break;
+                    case GM_Settings:
+                        switch(app.settings.selected_setting) {
+                        case 0:
+                            app.settings.sound_enabled = !app.settings.sound_enabled;
+                            break;
+                        case 1:
+                            app.settings.led_enabled = !app.settings.led_enabled;
+                            break;
+                        case 2:
+                            app.settings.vibrate_enabled = !app.settings.vibrate_enabled;
+                            break;
+                        case 3:
+                            app.settings.debug_mode = !app.settings.debug_mode;
+                            break;
+                        default:
+                            break;
+                        }
+                        break;
+                    default:
+                        break;
+                    }
+                    break;
+                default:
+                    break;
+                }
+            } else if(event.type == InputTypeRelease) {
+                if(event.key != InputKeyOk && event.key != InputKeyBack) {
+                    app.keys[event.key] = false;
+                    for(auto& f : app.table->flippers) {
+                        if(event.key == InputKeyLeft && f.side == Flipper::LEFT) {
+                            f.powered = false;
+                        } else if(event.key == InputKeyRight && f.side == Flipper::RIGHT) {
+                            f.powered = false;
+                        }
+                    }
+                }
+            }
+            // a key was pressed, reset idle counter
+            app.idle_start = furi_get_tick();
+        }
+
+        // update physics / motion
+        solve(&app, dt);
+        for(auto& o : app.table->objects) {
+            o->step_animation();
+        }
+
+        // check game state
+        if(app.game_mode != GM_GameOver && app.table->game_over) {
+            FURI_LOG_I(TAG, "GAME OVER!");
+            app.game_mode = GM_GameOver;
+            notify_game_over(&app);
+        }
+
+        // render
+        view_port_update(view_port);
+        furi_mutex_release(app.mutex);
+
+        // game timing + idle check
+        uint32_t current_tick = furi_get_tick();
+        if(current_tick - app.idle_start >= IDLE_TIMEOUT) {
+            FURI_LOG_W(TAG, "Idle timeout! Exiting Pinball0...");
+            app.processing = false;
+            break;
+        }
+
+        uint32_t time_lapsed = current_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;
+        }
+        app.tick++;
+        last_frame_time = furi_get_tick();
+    }
+
+    // general cleanup
+    view_port_enabled_set(view_port, false);
+    gui_remove_view_port(gui, view_port);
+    furi_record_close(RECORD_GUI);
+    view_port_free(view_port);
+    furi_message_queue_free(event_queue);
+
+    furi_timer_set_thread_priority(FuriTimerThreadPriorityNormal);
+    return 0;
+}

+ 87 - 0
pinball0/pinball0.h

@@ -0,0 +1,87 @@
+#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 <furi.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 "settings.h"
+
+// #define DRAW_NORMALS
+
+#define TAG     "Pinball0"
+#define VERSION "v0.4"
+
+// Vertical orientation
+#define LCD_WIDTH  64
+#define LCD_HEIGHT 128
+
+typedef enum GameMode {
+    GM_TableSelect,
+    GM_Playing,
+    GM_GameOver,
+    GM_Error,
+    GM_Settings,
+    GM_Tilted
+} GameMode;
+
+class TableList {
+public:
+    TableList() = default;
+    ~TableList() {
+        for(auto& mi : menu_items) {
+            furi_string_free(mi.name);
+            furi_string_free(mi.filename);
+        }
+    }
+
+    typedef struct {
+        FuriString* name;
+        FuriString* filename;
+    } TableMenuItem;
+
+    std::vector<TableMenuItem> menu_items;
+    int display_size; // how many can fit on screen
+    int selected;
+};
+
+class Table;
+
+typedef struct PinballApp {
+    PinballApp();
+    ~PinballApp();
+
+    bool initialized;
+
+    FuriMutex* mutex;
+
+    TableList table_list;
+
+    GameMode game_mode;
+    Table* table; // data for the current table
+    uint32_t tick;
+
+    bool keys[4]; // which key was pressed?
+    bool processing; // controls game loop and game objects
+    uint32_t idle_start; // tracks time of last key press
+
+    // user settings
+    PinballSettings settings;
+
+    // system objects
+    Storage* storage;
+    NotificationApp* notify; // allows us to blink/buzz during game
+    char text[256]; // general temp buffer
+
+} PinballApp;

BIN
pinball0/pinball0.png


BIN
pinball0/screenshots/lab_basic.png


BIN
pinball0/screenshots/lab_classic.png


BIN
pinball0/screenshots/lab_el_ocho.png


BIN
pinball0/screenshots/lab_menu.png


BIN
pinball0/screenshots/lab_splash.png


BIN
pinball0/screenshots/screenshot_basic.png


BIN
pinball0/screenshots/screenshot_chamber.png


BIN
pinball0/screenshots/screenshot_el_ocho.png


BIN
pinball0/screenshots/screenshot_menu.png


BIN
pinball0/screenshots/splash.png


+ 100 - 0
pinball0/settings.cxx

@@ -0,0 +1,100 @@
+#include <flipper_format/flipper_format.h>
+
+#include "settings.h"
+#include "pinball0.h"
+
+#define PINBALL_SETTINGS_FILENAME     ".pinball0.conf"
+#define PINBALL_SETTINGS_PATH         APP_DATA_PATH(PINBALL_SETTINGS_FILENAME)
+#define PINBALL_SETTINGS_FILE_TYPE    "Pinball0 Settings File"
+#define PINBALL_SETTINGS_FILE_VERSION 1
+
+void pinball_load_settings(PinballApp& pb) {
+    FlipperFormat* fff_settings = flipper_format_file_alloc(pb.storage);
+    FuriString* tmp_str = furi_string_alloc();
+    uint32_t tmp_data32 = 0;
+
+    PinballSettings& settings = pb.settings;
+    // init the settings to default values, then overwrite them if found in the settings file
+    settings.sound_enabled = true;
+    settings.led_enabled = true;
+    settings.vibrate_enabled = true;
+    settings.debug_mode = false;
+    settings.selected_setting = 0;
+    settings.max_settings = 4;
+
+    do {
+        if(!flipper_format_file_open_existing(fff_settings, PINBALL_SETTINGS_PATH)) {
+            FURI_LOG_I(TAG, "SETTINGS: File not found, using defaults");
+            break;
+        }
+        if(!flipper_format_read_header(fff_settings, tmp_str, &tmp_data32)) {
+            FURI_LOG_E(TAG, "SETTINGS: Missing or incorrect header");
+            break;
+        }
+        if(!strcmp(furi_string_get_cstr(tmp_str), PINBALL_SETTINGS_FILE_TYPE) &&
+           (tmp_data32 == PINBALL_SETTINGS_FILE_VERSION)) {
+        } else {
+            FURI_LOG_E(TAG, "SETTINGS: Type or version mismatch");
+            break;
+        }
+        if(flipper_format_read_uint32(fff_settings, "Sound", &tmp_data32, 1)) {
+            settings.sound_enabled = (tmp_data32 == 0) ? false : true;
+        }
+        if(flipper_format_read_uint32(fff_settings, "LED", &tmp_data32, 1)) {
+            settings.led_enabled = (tmp_data32 == 0) ? false : true;
+        }
+        if(flipper_format_read_uint32(fff_settings, "Vibrate", &tmp_data32, 1)) {
+            settings.vibrate_enabled = (tmp_data32 == 0) ? false : true;
+        }
+        if(flipper_format_read_uint32(fff_settings, "Debug", &tmp_data32, 1)) {
+            settings.debug_mode = (tmp_data32 == 0) ? false : true;
+        }
+
+    } while(false);
+
+    furi_string_free(tmp_str);
+    flipper_format_free(fff_settings);
+}
+
+void pinball_save_settings(PinballApp& pb) {
+    FlipperFormat* fff_settings = flipper_format_file_alloc(pb.storage);
+    uint32_t tmp_data32 = 0;
+    PinballSettings& settings = pb.settings;
+
+    FURI_LOG_I(TAG, "SETTINGS: Saving settings");
+    do {
+        if(!flipper_format_file_open_always(fff_settings, PINBALL_SETTINGS_PATH)) {
+            FURI_LOG_E(TAG, "SETTINGS: Unable to open file for save!");
+            break;
+        }
+        if(!flipper_format_write_header_cstr(
+               fff_settings, PINBALL_SETTINGS_FILE_TYPE, PINBALL_SETTINGS_FILE_VERSION)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed writing file type and version");
+            break;
+        }
+        // now write out our settings data
+        tmp_data32 = settings.sound_enabled ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "Sound", &tmp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'Sound'");
+            break;
+        }
+        tmp_data32 = settings.led_enabled ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "LED", &tmp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'LED'");
+            break;
+        }
+        tmp_data32 = settings.vibrate_enabled ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "Vibrate", &tmp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'Vibrate'");
+            break;
+        }
+        tmp_data32 = settings.debug_mode ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "Debug", &tmp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'Debug'");
+            break;
+        }
+    } while(false);
+
+    flipper_format_file_close(fff_settings);
+    flipper_format_free(fff_settings);
+}

+ 18 - 0
pinball0/settings.h

@@ -0,0 +1,18 @@
+#pragma once
+
+typedef struct {
+    bool sound_enabled;
+    bool vibrate_enabled;
+    bool led_enabled;
+    bool debug_mode;
+
+    int selected_setting;
+    int max_settings;
+} PinballSettings;
+
+struct PinballApp;
+// Read game settings from .pinball0.conf
+void pinball_load_settings(PinballApp& pb);
+
+// Save game settings to .pinball0.conf
+void pinball_save_settings(PinballApp& pb);

+ 243 - 0
pinball0/table.cxx

@@ -0,0 +1,243 @@
+#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 "pinball0.h"
+#include "graphics.h"
+#include "table.h"
+// #include "notifications.h"
+
+// Table defaults
+#define LIVES     3
+#define LIVES_POS Vec2(20, 20)
+
+void Lives::draw(Canvas* canvas) {
+    // we don't draw the last one, as it's in play!
+    constexpr float r = 20;
+    if(display && value > 0) {
+        float x = p.x;
+        float y = p.y;
+        float x_off = alignment == Align::Horizontal ? (2 * r) + r : 0;
+        float y_off = alignment == Align::Vertical ? (2 * r) + r : 0;
+        for(auto l = 0; l < value - 1; x += x_off, y += y_off, l++) {
+            gfx_draw_disc(canvas, x + r, y + r, 20);
+        }
+    }
+}
+
+void Score::draw(Canvas* canvas) {
+    if(display) {
+        char buf[32];
+        snprintf(buf, 32, "%d", value);
+        gfx_draw_str(canvas, p.x, p.y, AlignRight, AlignTop, buf);
+    }
+}
+
+Table::Table()
+    : game_over(false)
+    , balls_released(false)
+    , plunger(nullptr)
+    , tilt_detect_enabled(true)
+    , last_bump(furi_get_tick())
+    , bump_count(0) {
+}
+
+Table::~Table() {
+    for(size_t i = 0; i < objects.size(); i++) {
+        delete objects[i];
+    }
+    if(plunger != nullptr) {
+        delete plunger;
+    }
+}
+
+void Table::draw(Canvas* canvas) {
+    lives.draw(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);
+    }
+
+    // now draw flippers
+    for(auto& f : flippers) {
+        f.draw(canvas);
+    }
+
+    // is there a plunger in the house?
+    if(plunger) {
+        plunger->draw(canvas);
+    }
+
+    score.draw(canvas);
+}
+
+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);
+    // PinballApp* pb = (PinballApp*)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;
+}
+
+Table* table_init_table_settings(void* ctx) {
+    UNUSED(ctx);
+    Table* table = new Table();
+
+    // table->balls.push_back(Ball(Vec2(20, 880), 10));
+    // table->balls.back().add_velocity(Vec2(7, 0), .10f);
+    // table->balls.push_back(Ball(Vec2(610, 920), 10));
+    // table->balls.back().add_velocity(Vec2(-8, 0), .10f);
+    // table->balls.push_back(Ball(Vec2(250, 980), 10));
+    // 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);
+
+    return table;
+}
+
+bool table_load_table(void* ctx, size_t index) {
+    PinballApp* pb = (PinballApp*)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;
+    }
+
+    switch(index) {
+    case TABLE_SELECT:
+        pb->table = table_init_table_select(ctx);
+        break;
+    case TABLE_ERROR:
+        pb->table = table_init_table_error(ctx);
+        break;
+    case TABLE_SETTINGS:
+        pb->table = table_init_table_settings(ctx);
+        break;
+    default:
+        pb->table = table_load_table_from_file(pb, index - TABLE_INDEX_OFFSET);
+        break;
+    }
+    return pb->table != NULL;
+}

+ 88 - 0
pinball0/table.h

@@ -0,0 +1,88 @@
+#pragma once
+
+#include <furi.h>
+#include <vector>
+#include "pinball0.h"
+#include "objects.h"
+
+#define TABLE_SELECT       0
+#define TABLE_ERROR        1
+#define TABLE_SETTINGS     2
+#define TABLE_INDEX_OFFSET 3
+
+// Table display elements, rendered on the physical display coordinates,
+// not the table's scaled coords
+class DataDisplay {
+public:
+    enum Align {
+        Horizontal,
+        Vertical
+    };
+    DataDisplay(const Vec2& pos, int val, bool disp, Align align)
+        : p(pos)
+        , value(val)
+        , display(disp)
+        , alignment(align) {
+    }
+    Vec2 p;
+    int value;
+    bool display;
+    Align alignment;
+    virtual void draw(Canvas* canvas) = 0;
+};
+class Lives : public DataDisplay {
+public:
+    Lives()
+        : DataDisplay(Vec2(), 3, false, Horizontal) {
+    }
+    void draw(Canvas* canvas);
+};
+
+class Score : public DataDisplay {
+public:
+    Score()
+        : DataDisplay(Vec2(64 - 1, 1), 0, false, Horizontal) {
+    }
+    void draw(Canvas* canvas);
+};
+
+// Defines all of the elements on a pinball table:
+// edges, bumpers, flipper locations, scoreboard
+//
+// Also used for other app "views", like the main menu (table select)
+// and the Settings screen.
+// TODO: make this better? eh, it works for now...
+class Table {
+public:
+    Table();
+
+    ~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 game_over;
+    bool balls_released; // is ball in play?
+    Lives lives;
+    Score score;
+
+    Plunger* plunger;
+
+    // table bump / tilt tracking
+    bool tilt_detect_enabled;
+    uint32_t last_bump;
+    uint32_t bump_count;
+
+    void draw(Canvas* canvas);
+};
+
+// Read the list tables from the data folder and store in the state
+void table_table_list_init(void* ctx);
+
+// Reads the table file and creates the new table.
+Table* table_load_table_from_file(PinballApp* ctx, size_t index);
+
+// Loads the index'th table from the list
+bool table_load_table(void* ctx, size_t index);

+ 577 - 0
pinball0/table_parser.cxx

@@ -0,0 +1,577 @@
+#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"
+#include "notifications.h"
+
+namespace {
+bool ON_TABLE(const Vec2& p) {
+    return 0 <= p.x && p.x <= 630 && 0 <= p.y && p.y <= 1270;
+}
+};
+
+void table_table_list_init(void* ctx) {
+    PinballApp* pb = (PinballApp*)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")};
+    const size_t ext_len_max = 32;
+    char ext[ext_len_max];
+
+    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) {
+                path_extract_extension(table_path, ext, ext_len_max);
+                if(strcmp(ext, ".json") != 0) {
+                    FURI_LOG_W(
+                        TAG, "Skipping non-json file: %s", furi_string_get_cstr(table_path));
+                    continue;
+                }
+                const char* cpath = furi_string_get_cstr(table_path);
+
+                FuriString* filename_no_ext = furi_string_alloc();
+                path_extract_filename_no_ext(cpath, filename_no_ext);
+
+                // If filename starts with XX_ (for custom sorting) strip the prefix
+                char c = furi_string_get_char(filename_no_ext, 2);
+                if(c == '_') {
+                    char a = furi_string_get_char(filename_no_ext, 0);
+                    char b = furi_string_get_char(filename_no_ext, 1);
+                    if(a >= '0' && a <= '9' && b >= '0' && b <= '9') {
+                        furi_string_right(filename_no_ext, 3);
+                    }
+                }
+
+                if(!pb->settings.debug_mode &&
+                   !strncmp("dbg", furi_string_get_cstr(filename_no_ext), 3)) {
+                    furi_string_free(filename_no_ext);
+                    continue;
+                }
+
+                FURI_LOG_I(
+                    TAG,
+                    "Found table: name=%s | path=%s",
+                    furi_string_get_cstr(filename_no_ext),
+                    furi_string_get_cstr(table_path));
+
+                // set display 'name' and 'filename'
+                TableList::TableMenuItem tmi;
+                tmi.filename = furi_string_alloc_set_str(cpath);
+                tmi.name = filename_no_ext;
+
+                // 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);
+    }
+
+    // Add 'Settings' as last element
+    TableList::TableMenuItem settings;
+    settings.filename = furi_string_alloc_set_str("99_Settings");
+    settings.name = furi_string_alloc_set_str("SETTINGS");
+    pb->table_list.menu_items.push_back(settings);
+
+    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) {
+    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) {
+    const nx_json* item = nx_json_get(json, key);
+    if(!item) return false;
+    v = item->num.u_value;
+    return true;
+}
+
+bool table_file_parse_bool(const nx_json* json, const char* key, bool& v) {
+    int value = v == true ? 1 : 0; // set default value
+    if(table_file_parse_int(json, key, value)) {
+        v = value > 0 ? true : false;
+        return true;
+    }
+    return false;
+}
+
+bool table_file_parse_float(const nx_json* json, const char* key, float& 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(PinballApp* 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();
+
+    do {
+        const nx_json* lives = nx_json_get(json, "lives");
+        if(lives) {
+            table_file_parse_int(lives, "value", table->lives.value);
+            table_file_parse_bool(lives, "display", table->lives.display);
+            table_file_parse_vec2(lives, "position", table->lives.p);
+            const nx_json* align = nx_json_get(lives, "align");
+            if(align && !strcmp(align->text_value, "VERTICAL")) {
+                table->lives.alignment = Lives::Vertical;
+            }
+        }
+        const nx_json* tilt = nx_json_get(json, "tilt_detect");
+        if(tilt) {
+            table->tilt_detect_enabled = tilt->num.u_value > 0 ? true : false;
+        }
+        const nx_json* score = nx_json_get(json, "score");
+        if(score) {
+            table_file_parse_bool(score, "display", table->score.display);
+            table_file_parse_vec2(score, "position", table->score.p);
+        }
+
+        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);
+                if(!ball) continue;
+
+                Vec2 p;
+                if(!table_file_parse_vec2(ball, "position", p)) {
+                    FURI_LOG_E(TAG, "Ball missing \"position\", skipping");
+                    continue;
+                }
+                if(!ON_TABLE(p)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Ball with position %.1f,%.1f is not on table!",
+                        (double)p.x,
+                        (double)p.y);
+                }
+
+                Ball new_ball(p);
+                table_file_parse_float(ball, "radius", new_ball.r);
+
+                Vec2 v = (Vec2){0, 0};
+                table_file_parse_vec2(ball, "velocity", v);
+                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_W(
+                TAG, "Table has NO PLUNGER - s'ok, we don't really support one anyway (yet)");
+        }
+
+        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;
+                }
+                if(!ON_TABLE(p)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Flipper with position %.1f,%.1f is not on table!",
+                        (double)p.x,
+                        (double)p.y);
+                }
+
+                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);
+                // flip.notification = &notify_flipper;
+                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;
+                }
+                if(!ON_TABLE(p)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Bumper with position %.1f,%.1f is not on table!",
+                        (double)p.x,
+                        (double)p.y);
+                }
+
+                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);
+                new_bumper->bounce = bnc;
+                new_bumper->notification = notify_bumper_hit;
+                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;
+                }
+                if(!ON_TABLE(p)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Arc with position %.1f,%.1f is not on table!",
+                        (double)p.x,
+                        (double)p.y);
+                }
+
+                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;
+                }
+                if(!ON_TABLE(s)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Rail with starting position %.1f,%.1f is not on table!",
+                        (double)s.x,
+                        (double)s.y);
+                }
+                Vec2 e;
+                if(!table_file_parse_vec2(rail, "end", e)) {
+                    FURI_LOG_E(TAG, "Rail missing \"end\", skipping");
+                    continue;
+                }
+                if(!ON_TABLE(e)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Rail with ending position %.1f,%.1f is not on table!",
+                        (double)e.x,
+                        (double)e.y);
+                }
+
+                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();
+                new_rail->notification = &notify_rail_hit;
+                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();
+                    new_rail->notification = &notify_rail_hit;
+                    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;
+                }
+                if(!ON_TABLE(a1)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Portal A with starting position %.1f,%.1f is not on table!",
+                        (double)a1.x,
+                        (double)a1.y);
+                }
+                Vec2 a2;
+                if(!table_file_parse_vec2(portal, "a_end", a2)) {
+                    FURI_LOG_E(TAG, "Portal missing \"a_end\", skipping");
+                    continue;
+                }
+                if(!ON_TABLE(a2)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Portal A with ending position %.1f,%.1f is not on table!",
+                        (double)a2.x,
+                        (double)a2.y);
+                }
+                Vec2 b1;
+                if(!table_file_parse_vec2(portal, "b_start", b1)) {
+                    FURI_LOG_E(TAG, "Portal missing \"b_start\", skipping");
+                    continue;
+                }
+                if(!ON_TABLE(b1)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Portal B with starting position %.1f,%.1f is not on table!",
+                        (double)b1.x,
+                        (double)b1.y);
+                }
+                Vec2 b2;
+                if(!table_file_parse_vec2(portal, "b_end", b2)) {
+                    FURI_LOG_E(TAG, "Portal missing \"b_end\", skipping");
+                    continue;
+                }
+                if(!ON_TABLE(b2)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Portal B with ending position %.1f,%.1f is not on table!",
+                        (double)b2.x,
+                        (double)b2.y);
+                }
+
+                Portal* new_portal = new Portal(a1, a2, b1, b2);
+                new_portal->finalize();
+                new_portal->notification = &notify_portal;
+                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;
+                }
+                if(!ON_TABLE(p)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Rollover with position %.1f,%.1f is not on table!",
+                        (double)p.x,
+                        (double)p.y);
+                }
+                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;
+                }
+                if(!ON_TABLE(p)) {
+                    FURI_LOG_W(
+                        TAG,
+                        "Turbo with position %.1f,%.1f is not on table!",
+                        (double)p.x,
+                        (double)p.y);
+                }
+                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(false);
+
+    nx_json_free(json);
+    free(json_buffer);
+
+    return table;
+}

+ 16 - 0
pinball0/vec2.cxx

@@ -0,0 +1,16 @@
+#include <stdbool.h>
+#include <math.h>
+
+#include "vec2.h"
+
+// 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;
+}

+ 102 - 0
pinball0/vec2.h

@@ -0,0 +1,102 @@
+#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));
+    }
+};
+
+inline Vec2 operator*(float s, const Vec2& v) {
+    return Vec2(s * v.x, s * v.y);
+}
+
+// // Returns the closest point to the line segment ab and p
+Vec2 Vec2_closest(const Vec2& a, const Vec2& b, const Vec2& p);