<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.2.0">Jekyll</generator><link href="https://jorislacance.fr/feed.xml" rel="self" type="application/atom+xml" /><link href="https://jorislacance.fr/" rel="alternate" type="text/html" /><updated>2025-10-08T16:10:09-05:00</updated><id>https://jorislacance.fr/feed.xml</id><title type="html">Joris La Cancellera</title><subtitle>Portfolio of Joris La Cancellera, Software Engineer the day, Game Developer and Hacker some nights 🌌.</subtitle><entry><title type="html">Poto-Siril</title><link href="https://jorislacance.fr/projects/poto-siril" rel="alternate" type="text/html" title="Poto-Siril" /><published>2024-10-15T17:12:00-05:00</published><updated>2024-10-15T17:12:00-05:00</updated><id>https://jorislacance.fr/projects/poto-siril</id><content type="html" xml:base="https://jorislacance.fr/projects/poto-siril"></content><author><name></name></author><category term="projects" /><category term="astro" /><category term="top-pick" /><category term="open source" /><category term="software" /><category term="astrophotography" /><summary type="html"></summary></entry><entry><title type="html">Rain Alarm for Astrophotography proto 3 - Siren Alarm</title><link href="https://jorislacance.fr/blog/2023/12/01/rafa-proto-3" rel="alternate" type="text/html" title="Rain Alarm for Astrophotography proto 3 - Siren Alarm" /><published>2023-12-01T14:00:00-06:00</published><updated>2023-12-01T14:00:00-06:00</updated><id>https://jorislacance.fr/blog/2023/12/01/rafa-proto-3</id><content type="html" xml:base="https://jorislacance.fr/blog/2023/12/01/rafa-proto-3">&lt;p&gt;You’re reading part 3 of the &lt;em&gt;Rain Alarm for Astrophotography&lt;/em&gt; series; &lt;a href=&quot;/blog/2023/08/27/rafa-proto-1&quot;&gt;part 1 is available here 👋&lt;/a&gt; and &lt;a href=&quot;/blog/2023/10/24/rafa-proto-2&quot;&gt;part 2 here 👋&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-sound-of-silence&quot;&gt;The sound of silence&lt;/h2&gt;

&lt;p&gt;Part of the project is to wake the amateur astronomer up when it starts raining 🌧️. Or when we lose the communication with the rain sensor.&lt;br /&gt;
We will naturally embed a siren alarm in the base station to do that!&lt;/p&gt;

&lt;p&gt;In this sound alarm prototype, I did not have a clear idea of what I was aiming for: a simple beep sound? 8-bit music? MP3/WAV music or ringtone? I was also unsure about the technical implications, so &lt;strong&gt;I explored many options&lt;/strong&gt;. I must admit that I got a bit lost and undecided during the journey.&lt;/p&gt;

&lt;p&gt;Indeed, we have a lot of options to produce a sound with electronics, each with its own pros and cons. Let’s look at them.&lt;/p&gt;

&lt;h2 id=&quot;buzzers-vs-speakers&quot;&gt;Buzzers vs Speakers&lt;/h2&gt;

&lt;p&gt;It seems we have two primary hardware components to produce a sound for us:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Piezoelectric buzzers&lt;/strong&gt;: Quite cheap and easy to use, but they produce a very annoying sound. That’s the usual beep sound you hear very often on “non-media” electronic devices. They are divided into 2 sub-categories:&lt;/p&gt;

    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;Active buzzers&lt;/strong&gt;: You just need to power them to produce a fixed-pitch sound, thanks to an internal oscillator.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Passive buzzers&lt;/strong&gt;: You need to supply an AC signal to produce a sound, and the pitch is affected by the frequency of that signal.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/rafa3/buzzers.jpg&quot; class=&quot;border col-lg-4&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Speakers&lt;/strong&gt;: They produce a much more pleasant sound, but they require more hardware to drive them correctly (an amplifier at minimal, we will get back to this later). They need an AC signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/rafa3/speakers.jpg&quot; class=&quot;border col-lg-4&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;architecture-setups&quot;&gt;Architecture setups&lt;/h2&gt;

&lt;p&gt;Having said that, we have plenty of solutions when it comes to the integration:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Active Buzzer&lt;/strong&gt;: This is the simplest solution. It’s as easy as turning a LED ON. Though, I really don’t want a fixed tone ringing.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Passive Buzzer + PWM Modulation⭐&lt;/strong&gt;: Slightly more complex than above but still fairly simple: we’re using &lt;a href=&quot;https://espressif-docs.readthedocs-hosted.com/projects/arduino-esp32/en/latest/api/ledc.html&quot;&gt;LEDC&lt;/a&gt; (LED control) with the ESP32 to generate a PWM signal to control the pitch. A small capacitor can more or less smooth the squared signal into an AC shape to fine-tune the sound. Sometimes science is more art than science, Morty.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Speaker + Internal ESP32 DAC&lt;/strong&gt; (digital-analog converter): The idea here is to use the ESP32’s internal DAC to produce a modulated sinusoidal waveform. The DAC is an 8-bit one, which will be okay for &lt;a href=&quot;https://en.wikipedia.org/wiki/Chiptune&quot;&gt;Chiptune&lt;/a&gt; but not ideal for regular 16-32 bit music. However, the output of the DAC cannot be used to drive a speaker directly because a speaker requires 4-8 Watts of power (that’s 1A in our 3.3V logic circuit!🚒). Therefore, an amplifier is needed in between to “repeat” the logic circuit signal with higher power. The core issue I have with this design is that it looks intimidating code-wise, as the music sampling rate will depend on the CPU clock frequency. This means that we would need to isolate a part of the ESP32 resources (perhaps using interrupt timers?) for the music, while still dealing with radio communication, etc. This solution is too low-level for me. The discovery of the &lt;a href=&quot;https://sensorium.github.io/Mozzi&quot;&gt;Mozzi&lt;/a&gt; library later confirmed my doubts even further.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Speaker + External DAC&lt;/strong&gt;: We can use an external DAC (16-Bit) to produce better sound. The DAC will still need to be connected to an amplifier to drive the speaker, but it’s common to see the two functions bundled together. That’s the case with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MAX98357A&lt;/code&gt; I’ve tried. We connect this dude to the ESP32 via the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;I2S&lt;/code&gt; (Inter-IC Sound) protocol. Unlike the previous method, playing a sound is easier on the software side as we deal with highly abstracted libraries. During my tests, I couldn’t achieve this without adding an SD card reader to stream the music from. In another attempt, I would improve this to stream from the ESP32 flash memory or just accept the SD card reader, enabling everyone to customize the alarm sound.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Speaker + PWM Modulation&lt;/strong&gt;: This was a silly idea I had, thinking I could replace a passive buzzer with a speaker. It turns out this would work, but an amplifier is needed.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;exploration-results&quot;&gt;Exploration results&lt;/h3&gt;

&lt;p&gt;Now that we have a good overview of the options, we can break the results as follows:&lt;/p&gt;

&lt;table class=&quot;table table-striped table-sm table-bordered&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Option&lt;/th&gt;
      &lt;th&gt;Can do&lt;/th&gt;
      &lt;th&gt;Complexity&lt;/th&gt;
      &lt;th&gt;Cost&lt;/th&gt;
      &lt;th&gt;Reliability&lt;/th&gt;
      &lt;th&gt;Bill of materials&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Active Buzzer&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Monotone melody&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;buzzer&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Passive Buzzer + PWM Modulation&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Monophony&quot;&gt;Monophony&lt;/a&gt; melody&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;buzzer&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Speaker + Internal ESP32 DAC&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Monophony melody&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;speaker + amplifier&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Speaker + External DAC &lt;em&gt;(+ SD Card Reader)&lt;/em&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://youtu.be/ZNR8wYjK46U?si=shiTDclWQBCJbxoP&amp;amp;t=53&quot;&gt;Everything 🥯&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;++ / &lt;em&gt;(+++)&lt;/em&gt;&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;++ / &lt;em&gt;(+)&lt;/em&gt;&lt;/td&gt;
      &lt;td&gt;speaker + amplifier + DAC &lt;em&gt;(+SD card reader)&lt;/em&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Speaker + PWM Modulation&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Monophony melody&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;speaker + amplifier&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The use of a speaker introduces a lot of extras. I haven’t even scratched the surface of the software challenges, such as storing the music files and bundling extra libraries into the build. If using an SD Card is absolutely unavoidable, that would mean having two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SPI&lt;/code&gt; devices (the SD card reader and the LoRa module, from &lt;a href=&quot;/blog/2023/10/24/rafa-proto-2&quot;&gt;part 2&lt;/a&gt;), necessitating &lt;a href=&quot;https://docs.espressif.com/projects/esp-idf/en/v5.1.1/esp32/api-reference/peripherals/sdspi_share.html&quot;&gt;sharing the SPI bus&lt;/a&gt;. My board has a second &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SPI&lt;/code&gt; bus, but it seems unusable to me: one of the pins must be at 0V at boot time 🤦‍♀️, a requirement I discovered when failing to boot.&lt;/p&gt;

&lt;p&gt;Reflecting on our product values (simplicity, reliability…), it becomes clear that a pragmatic decision is to opt for a &lt;strong&gt;Passive Buzzer + PWM Modulation&lt;/strong&gt; design.&lt;/p&gt;

&lt;h2 id=&quot;protoyping-the-sound-alarm&quot;&gt;Protoyping the sound alarm&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;Passive Buzzer + PWM Modulation&lt;/strong&gt; and &lt;strong&gt;Speaker + External DAC + SD Card Reader&lt;/strong&gt; architectures are demonstrated in the following video. 🔊🔊🔊&lt;/p&gt;

&lt;div class=&quot;embed-responsive embed-responsive-16by9&quot;&gt;
&lt;link href=&quot;https://vjs.zencdn.net/8.5.2/video-js.css&quot; rel=&quot;stylesheet&quot; /&gt;
&lt;video id=&quot;my-video&quot; class=&quot;video-js embed-responsive-item&quot; controls=&quot;&quot; preload=&quot;auto&quot; width=&quot;1000&quot; height=&quot;563&quot; poster=&quot;/assets/blog/rafa3/proto-demo-3.jpg&quot; data-setup=&quot;{}&quot;&gt;
&lt;source src=&quot;/assets/blog/rafa3/proto-demo-3.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;p class=&quot;vjs-no-js&quot;&gt;
    To view this video, please enable JavaScript, and consider upgrading to a
    web browser that
    &lt;a href=&quot;https://videojs.com/html5-video-support/&quot; target=&quot;_blank&quot;&gt;supports HTML5 video&lt;/a&gt;
&lt;/p&gt;
&lt;/video&gt;
&lt;script src=&quot;https://vjs.zencdn.net/8.5.2/video.min.js&quot;&gt;&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;&lt;small&gt;The buzzer chiptune looks really good. On the other hand, you can notice that the speaker volume isn’t stable, I don’t know why at this point but it’s not a power delivery issue.&lt;/small&gt;
&lt;br /&gt;&lt;/p&gt;

&lt;h2 id=&quot;schematic&quot;&gt;Schematic&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/rafa3/schematic-rafa3.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;From here, we really see the difference in terms of complexity between the two architectures.&lt;/p&gt;

&lt;h2 id=&quot;take-away&quot;&gt;Take away&lt;/h2&gt;

&lt;p&gt;We’ve learned a few things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The &lt;strong&gt;Passive Buzzer + PWM Modulation&lt;/strong&gt; is the best option for our use case. The sound is loud enough, and the quality is certainly adequate for chiptune sounds. It enables us to create distinctly different sounds for each type of alarm (rain or communication lost), unlike the Active Buzzer.&lt;/li&gt;
  &lt;li&gt;We spent a considerable amount of time exploring this, but it was fun. I mean, &lt;strong&gt;this is an alarm you’re best off never hearing throughout the entire product lifecycle&lt;/strong&gt;, so we could have omitted the idea of using speakers from the start.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;next-step&quot;&gt;Next step&lt;/h2&gt;

&lt;p&gt;We have two more things to prototype:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Temperature and Humidity sensors for the &lt;strong&gt;rain sensor&lt;/strong&gt; module.&lt;/li&gt;
  &lt;li&gt;A screen 📺 for the &lt;strong&gt;base station&lt;/strong&gt; should follow as well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then, it will be about integrating all of these components together. 🏆&lt;/p&gt;

&lt;h2 id=&quot;source-code&quot;&gt;Source code&lt;/h2&gt;

&lt;h3 id=&quot;passive-buzzer--pwm-modulation&quot;&gt;Passive Buzzer + PWM Modulation&lt;/h3&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/1fa4bbdba0b1d271cc5edc6e51eb4405.js&quot;&gt;&lt;/script&gt;

&lt;h3 id=&quot;speaker--external-dac--sd-card-reader&quot;&gt;Speaker + External DAC + SD Card Reader&lt;/h3&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/fbf0ef30116e8cab8df565ccf5083727.js&quot;&gt;&lt;/script&gt;</content><author><name></name></author><category term="blog" /><category term="rafa" /><category term="astrophotography" /><category term="DIY" /><category term="electronics" /><summary type="html">You’re reading part 3 of the Rain Alarm for Astrophotography series; part 1 is available here 👋 and part 2 here 👋.</summary></entry><entry><title type="html">Rain Alarm for Astrophotography proto 2 - Wireless Communication</title><link href="https://jorislacance.fr/blog/2023/10/24/rafa-proto-2" rel="alternate" type="text/html" title="Rain Alarm for Astrophotography proto 2 - Wireless Communication" /><published>2023-10-24T16:03:00-05:00</published><updated>2023-10-24T16:03:00-05:00</updated><id>https://jorislacance.fr/blog/2023/10/24/rafa-proto-2</id><content type="html" xml:base="https://jorislacance.fr/blog/2023/10/24/rafa-proto-2">&lt;p&gt;You’re reading part 2 of the &lt;em&gt;Rain Alarm for Astrophotography&lt;/em&gt; series; &lt;a href=&quot;/blog/2023/08/27/rafa-proto-1&quot;&gt;part 1 is available here 👋&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-need-for-radio-communication&quot;&gt;The need for radio communication&lt;/h2&gt;

&lt;p&gt;As previously discussed, we intend for the &lt;strong&gt;rain sensor&lt;/strong&gt; device to remain outdoors alongside the unattended astrophotography equipment, in anticipation of rain showers 🌧️.
Additionally, the &lt;strong&gt;base station&lt;/strong&gt; will be located indoors so that the alarm can be heard within the house.&lt;/p&gt;

&lt;p&gt;We want the two devices to reach each other &lt;strong&gt;from a distance of 50-100 meters&lt;/strong&gt;, with some walls in between.&lt;/p&gt;

&lt;h2 id=&quot;what-radio-communication-technology&quot;&gt;What radio communication technology?&lt;/h2&gt;

&lt;p&gt;There, we have some options:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;WiFi&lt;/strong&gt;: There are two approaches to consider. Both devices connect to the same home network, or one device—likely the base station—acts as an access point for the other to connect to. While I have full coverage in my own garden, I got to point out that the garden is not particularly long, and I bought an expensive dedicated WiFi router to get that coverage. WiFi won’t be a viable option for our plug and play product.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Bluetooth&lt;/strong&gt;: Quite famous for IoT. What makes it promising are reports suggesting that BLE 5.0 could achieve a transmission distance of 200 meters outdoors and 40 meters indoors. I won’t eliminate this option just yet.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Zigbee&lt;/strong&gt;: I love the Zigbee tech, but it’s not suitable for this type of project. Designed for mesh networks, it’s not optimized for long-range, 1-to-1 communication.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cellular&lt;/strong&gt;: That’s a solid option but I don’t want to go there to stay low tech.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;LoRa⭐&lt;/strong&gt;: This is IoT centered, low power, long distance (from 1 to 10km!), and quite low level to tune the bandwidth/range tradeoff. The modules are not too expensive and versatile: they support many other radio modulations, such as FSK. 👉 This appears to be the best choice.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;hardware&quot;&gt;Hardware&lt;/h2&gt;

&lt;h3 id=&quot;the-microcontroller&quot;&gt;The microcontroller&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;/blog/2023/08/27/rafa-proto-1&quot;&gt;Proto 1&lt;/a&gt; was built with an Arduino Uno (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ATmega328P&lt;/code&gt; microcontroller). This was the only hardware available around me at that time and it did the job very well. For this second prototype, I’m looking for something smaller, that I can embed in a box, aiming to stick to the same microcontroller for the rest of the project.&lt;/p&gt;

&lt;p&gt;Every Raspberry pi like boards are out of the question, as I don’t want a full computer here.&lt;/p&gt;

&lt;p&gt;I considered using an Arduino Nano and ended up wasting two nights fighting with a cheap knockoff. I was surprised to discover that the low-cost suppliers not only produce bad-quality clones (Arduino being open source), but also cut costs by replacing some expensive components, like the serial-usb converter &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CP2102&lt;/code&gt; for some shittier alternative, like the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CH340&lt;/code&gt;. So yeah, I struggled with Windows 11 automatically updating the driver, which then made the OS stop recognizing the board with the latest driver version. Plus, whenever I connected the second board, it made the first one disconnect. Super annoying!&lt;br /&gt;
The genuine-official Arduino Nano cost 20€, that I need twice for my project: 50 euros with the shipping. This is a quite expensive material if I want to make a product out of this.&lt;/p&gt;

&lt;p&gt;I went for a pair of ESP32 microcontroller (ESP32 Wroom 32E) from &lt;a href=&quot;https://www.upesy.fr/products/upesy-esp32-wroom-devkit-board&quot;&gt;uPesy.fr&lt;/a&gt;. Yup, made right here in France 🇫🇷🥖!&lt;br /&gt;
I also migrated from the Arduino IDE to platformIO for the coding experience. Things are getting serious here.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/rafa2/boards.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;From left to right: Arduino Uno, Arduino Nano &amp;amp; ESP32.&lt;/small&gt;&lt;/p&gt;

&lt;h3 id=&quot;the-radio-module&quot;&gt;The radio module&lt;/h3&gt;

&lt;p&gt;The ESP32 comes with built-in WiFi and Bluetooth, but it lacks LoRa support. So, we’ll need a separate chip to handle LoRa. I chose the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XL1278-SMT&lt;/code&gt;, which is based on Semtech’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sx1278&lt;/code&gt;. This module runs on 3.3v for both power and logic levels, just like the ESP32 board hopefully. That’s different from Arduino boards, which operate at 5v.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/rafa2/sx1278.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;This tiny evil thing has 1.27mm pin headers that I had to bridge to 2.54mm pin headers for my breadboard.&lt;/small&gt;&lt;/p&gt;

&lt;h2 id=&quot;protoyping-the-radio-communication&quot;&gt;Protoyping the radio communication&lt;/h2&gt;

&lt;p&gt;For this prototype, I’ve set up a button that toggles the ESP32’s built-in blue LEDs when pressed. The transmitter board on the left uses direct electrical wiring to control the LED, while the receiver board on the right gets the button status via LoRa and acts accordingly.&lt;/p&gt;

&lt;div class=&quot;embed-responsive embed-responsive-16by9&quot;&gt;
&lt;link href=&quot;https://vjs.zencdn.net/8.5.2/video-js.css&quot; rel=&quot;stylesheet&quot; /&gt;
&lt;video id=&quot;my-video&quot; class=&quot;video-js embed-responsive-item&quot; controls=&quot;&quot; loop=&quot;&quot; autoplay=&quot;&quot; preload=&quot;auto&quot; width=&quot;1000&quot; height=&quot;563&quot; poster=&quot;/assets/blog/rafa2/proto-demo-2.jpg&quot; data-setup=&quot;{}&quot;&gt;
&lt;source src=&quot;/assets/blog/rafa2/proto-demo-2.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;p class=&quot;vjs-no-js&quot;&gt;
    To view this video, please enable JavaScript, and consider upgrading to a
    web browser that
    &lt;a href=&quot;https://videojs.com/html5-video-support/&quot; target=&quot;_blank&quot;&gt;supports HTML5 video&lt;/a&gt;
&lt;/p&gt;
&lt;/video&gt;
&lt;script src=&quot;https://vjs.zencdn.net/8.5.2/video.min.js&quot;&gt;&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;&lt;small&gt;This is amusing, as there’s no security in place right now. Who knows, someone might think I’m sending out Morse code messages!&lt;/small&gt;
&lt;br /&gt;&lt;/p&gt;

&lt;h2 id=&quot;schematic&quot;&gt;Schematic&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/rafa2/schematic-rafa2.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Both the &lt;strong&gt;ESP32&lt;/strong&gt; and the &lt;strong&gt;XL1278-SMT&lt;/strong&gt; LoRa module connect in the same way on both circuits, using the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SPI bus&lt;/code&gt;. The uPesy board features a tiny blue LED, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D1&lt;/code&gt;, connected to the GPIO2 output.&lt;/li&gt;
  &lt;li&gt;I’m using a switch, labeled &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SW1&lt;/code&gt;, to simulate a rain trigger. The switch is connected to the ESP32 board via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GPIO15&lt;/code&gt;, configured as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;INPUT_PULLUP&lt;/code&gt;. The board comes with a built-in pull-up resistor, so there’s no need for an external one 💝.&lt;/li&gt;
  &lt;li&gt;When you press the switch &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SW1&lt;/code&gt;, it changes the state of the &lt;strong&gt;rain sensor&lt;/strong&gt;’s LED, labeled &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D1&lt;/code&gt;. The &lt;strong&gt;base station&lt;/strong&gt;’s LED follows the switch state as well thanks to the wireless com.&lt;/li&gt;
  &lt;li&gt;Lastly, I needed a capacitor &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;C1&lt;/code&gt; to stabilise the boot of the &lt;strong&gt;XL1278-SMT&lt;/strong&gt; module on the transmitter side, due to some voltage fluctuation issues.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;take-away&quot;&gt;Take away&lt;/h2&gt;

&lt;p&gt;We’ve learn a few things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The ESP32 is looking pretty solid&lt;/strong&gt;, it’s safe to assume this will be the board I will use for the final product.&lt;/li&gt;
  &lt;li&gt;LoRa rocks too in terms of range and reliability. &lt;strong&gt;I was able to reach 200 meters of coverage&lt;/strong&gt; with a 100% stable transmission between my house and outside on the street, with countless walls in between. That’s very good considering having not fine tuned anything and the poor PCB antenna, and I don’t even need more than that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;next-step&quot;&gt;Next step&lt;/h2&gt;

&lt;p&gt;Next on the agenda: speakers for the base station.&lt;/p&gt;

&lt;p&gt;There’s a bunch of tweaks to make on this prototype, like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Switching the ESP32 to deep sleep to kill the Bluetooth and WiFi, dropping from 200mA to 20mA (easy).&lt;/li&gt;
  &lt;li&gt;Leveraging ESP32 timers so we don’t have to poll the switch or blast LoRa packets at CPU clock speed (medium-level stuff).&lt;/li&gt;
  &lt;li&gt;Getting the LoRa module into sleep mode and wake up when needed (tricky).&lt;/li&gt;
  &lt;li&gt;Secure the LoRa communication (medium-level stuff).&lt;/li&gt;
  &lt;li&gt;Buy the proper version of the LoRa to comply with EU regulation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href=&quot;/blog/2023/12/01/rafa-proto-3&quot;&gt;Part 3 is available here 👋&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;source-code&quot;&gt;Source code&lt;/h2&gt;

&lt;h3 id=&quot;rain-sensor-transmitter&quot;&gt;Rain Sensor Transmitter&lt;/h3&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/ba15e88fb859e4b6b4bfe53a8dce0dfb.js&quot;&gt;&lt;/script&gt;

&lt;h3 id=&quot;base-station-receiver&quot;&gt;Base Station Receiver&lt;/h3&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/a9188eaf0eb292d1ef9a265f314c5eed.js&quot;&gt;&lt;/script&gt;</content><author><name></name></author><category term="blog" /><category term="rafa" /><category term="astrophotography" /><category term="DIY" /><category term="electronics" /><summary type="html">You’re reading part 2 of the Rain Alarm for Astrophotography series; part 1 is available here 👋.</summary></entry><entry><title type="html">RAfA (Rain Alarm for Astrophotography)</title><link href="https://jorislacance.fr/projects/rafa" rel="alternate" type="text/html" title="RAfA (Rain Alarm for Astrophotography)" /><published>2023-09-01T18:00:00-05:00</published><updated>2023-09-01T18:00:00-05:00</updated><id>https://jorislacance.fr/projects/rafa</id><content type="html" xml:base="https://jorislacance.fr/projects/rafa">&lt;p&gt;&lt;strong&gt;RAfA&lt;/strong&gt; (Rain Alarm for Astrophotography) is a plug-and-play system designed for amateur astronomers. It allows them to leave their astrophotography equipment running outside overnight, knowing that they will be notified in case of any unexpected rain.&lt;/p&gt;

&lt;p&gt;More to come soon!&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;I’m hosting a series of blog posts about the project:&lt;/p&gt;

&lt;p&gt;&lt;br /&gt;&lt;/p&gt;
&lt;div class=&quot;container&quot;&gt;
    &lt;ul&gt;
        
        &lt;div class=&quot;row mb-100&quot;&gt;
            &lt;div class=&quot;col-md-3 text-right-widescreen&quot;&gt;
                &lt;a class=&quot;text-link&quot; href=&quot;/blog/2023/08/27/rafa-proto-1&quot;&gt;&lt;img class=&quot;list-blog-img&quot; src=&quot;/assets/blog/rafa1/mainImage.jpg&quot; /&gt;&lt;/a&gt;
            &lt;/div&gt;
            &lt;div class=&quot;col-lg-6 pt-2&quot;&gt;
                &lt;h4&gt;
                    &lt;a class=&quot;text-link&quot; href=&quot;/blog/2023/08/27/rafa-proto-1&quot;&gt;Rain Alarm for Astrophotography proto 1 - Rain Sensor&lt;/a&gt;
                    
                &lt;/h4&gt;
                &lt;p class=&quot;text-muted&quot; style=&quot;margin-bottom: 0px&quot;&gt;27 August 2023
                    
                    &lt;span class=&quot;badge badge-secondary&quot;&gt;astrophotography&lt;/span&gt;
                    
                    &lt;span class=&quot;badge badge-secondary&quot;&gt;DIY&lt;/span&gt;
                    
                    &lt;span class=&quot;badge badge-secondary&quot;&gt;electronics&lt;/span&gt;
                    
                &lt;/p&gt;
            &lt;/div&gt;
        &lt;/div&gt;
        
        &lt;div class=&quot;row mb-100&quot;&gt;
            &lt;div class=&quot;col-md-3 text-right-widescreen&quot;&gt;
                &lt;a class=&quot;text-link&quot; href=&quot;/blog/2023/10/24/rafa-proto-2&quot;&gt;&lt;img class=&quot;list-blog-img&quot; src=&quot;/assets/blog/rafa2/mainImage.jpg&quot; /&gt;&lt;/a&gt;
            &lt;/div&gt;
            &lt;div class=&quot;col-lg-6 pt-2&quot;&gt;
                &lt;h4&gt;
                    &lt;a class=&quot;text-link&quot; href=&quot;/blog/2023/10/24/rafa-proto-2&quot;&gt;Rain Alarm for Astrophotography proto 2 - Wireless Communication&lt;/a&gt;
                    
                &lt;/h4&gt;
                &lt;p class=&quot;text-muted&quot; style=&quot;margin-bottom: 0px&quot;&gt;24 October 2023
                    
                    &lt;span class=&quot;badge badge-secondary&quot;&gt;astrophotography&lt;/span&gt;
                    
                    &lt;span class=&quot;badge badge-secondary&quot;&gt;DIY&lt;/span&gt;
                    
                    &lt;span class=&quot;badge badge-secondary&quot;&gt;electronics&lt;/span&gt;
                    
                &lt;/p&gt;
            &lt;/div&gt;
        &lt;/div&gt;
        
        &lt;div class=&quot;row mb-100&quot;&gt;
            &lt;div class=&quot;col-md-3 text-right-widescreen&quot;&gt;
                &lt;a class=&quot;text-link&quot; href=&quot;/blog/2023/12/01/rafa-proto-3&quot;&gt;&lt;img class=&quot;list-blog-img&quot; src=&quot;/assets/blog/rafa3/mainImage.jpg&quot; /&gt;&lt;/a&gt;
            &lt;/div&gt;
            &lt;div class=&quot;col-lg-6 pt-2&quot;&gt;
                &lt;h4&gt;
                    &lt;a class=&quot;text-link&quot; href=&quot;/blog/2023/12/01/rafa-proto-3&quot;&gt;Rain Alarm for Astrophotography proto 3 - Siren Alarm&lt;/a&gt;
                    
                &lt;/h4&gt;
                &lt;p class=&quot;text-muted&quot; style=&quot;margin-bottom: 0px&quot;&gt;01 December 2023
                    
                    &lt;span class=&quot;badge badge-secondary&quot;&gt;astrophotography&lt;/span&gt;
                    
                    &lt;span class=&quot;badge badge-secondary&quot;&gt;DIY&lt;/span&gt;
                    
                    &lt;span class=&quot;badge badge-secondary&quot;&gt;electronics&lt;/span&gt;
                    
                &lt;/p&gt;
            &lt;/div&gt;
        &lt;/div&gt;
        
    &lt;/ul&gt;
&lt;/div&gt;</content><author><name></name></author><category term="projects" /><category term="product" /><category term="top-pick" /><category term="hardware" /><category term="embedded" /><category term="electronics" /><summary type="html">RAfA (Rain Alarm for Astrophotography) is a plug-and-play system designed for amateur astronomers. It allows them to leave their astrophotography equipment running outside overnight, knowing that they will be notified in case of any unexpected rain.</summary></entry><entry><title type="html">Rain Alarm for Astrophotography proto 1 - Rain Sensor</title><link href="https://jorislacance.fr/blog/2023/08/27/rafa-proto-1" rel="alternate" type="text/html" title="Rain Alarm for Astrophotography proto 1 - Rain Sensor" /><published>2023-08-27T07:53:00-05:00</published><updated>2023-08-27T07:53:00-05:00</updated><id>https://jorislacance.fr/blog/2023/08/27/rafa-proto-1</id><content type="html" xml:base="https://jorislacance.fr/blog/2023/08/27/rafa-proto-1">&lt;p&gt;So, asking around and searching online, there is no evidence of a ready-to-buy rain alarm that would fit for astrophotography.&lt;br /&gt;
I’m going to build one.&lt;/p&gt;

&lt;h2 id=&quot;need-context&quot;&gt;Need context&lt;/h2&gt;

&lt;p&gt;Deep-sky astrophotography involves capturing images of deep-sky objects (DSOs) such as galaxies, nebulae, and star clusters.&lt;br /&gt;
You’ll need to &lt;b&gt;accumulate hours of exposure time&lt;/b&gt; and process the data to reveal this faint and ancient light from deep space.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/rafa1/m51.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Messier 51, or the Whirlpool Galaxy, with 60 x 60 seconds of lights data on a small refracting telescope 72ED.&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;Astrophotography is all about hauling your pricey gear into your yard, maybe on a random Tuesday night because the sky and moon are playing nice, even though you’re clocking in the next morning. You spend an hour getting everything up and running, and there nothing to do anymore as it runs on its own: &lt;b&gt;at this point, you just want to go back inside and sleep&lt;/b&gt;.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;But what happens if the forecast is poor and it rains overnight?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I interviewed several astrophotographers, and they either said they rely on hope or joked about taking sleep aids to sleep.&lt;br /&gt;
Personally, I relied on some rain alert mobile apps, but a week of evaluation showed they can still produce false positives and negatives. Additionally, the app may be draining my phone’s battery so the phone kills it first.&lt;/p&gt;

&lt;p&gt;I’ll take my chance with a DIY rain alarm.&lt;/p&gt;

&lt;h2 id=&quot;scoping-the-project&quot;&gt;Scoping the project&lt;/h2&gt;

&lt;p&gt;As a dad, the struggle isn’t foreign to me at all—it’s pretty much identical to having a snoozing infant. You’re torn between catching some sleep and making sure your baby is okay.&lt;/p&gt;

&lt;p&gt;👉 And you invest in a baby monitor.&lt;/p&gt;

&lt;p&gt;That’s exactly what I want to do: &lt;b&gt;create a baby monitor for astrophotography&lt;/b&gt;.&lt;/p&gt;

&lt;h2 id=&quot;early-specs&quot;&gt;Early specs&lt;/h2&gt;

&lt;p&gt;We’re going to build a device with the same characteristics that led me to choose my baby monitor:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Plug and play and self-sufficient: no need to connect to a computer or mobile phone.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No third dependencies&lt;/strong&gt;: No mobile app, no Cloud (the device will not be dead because of &lt;em&gt;f-word&lt;/em&gt; software obsolescence).&lt;/li&gt;
  &lt;li&gt;Fair wireless range: No WiFi, No Bluetooh.&lt;/li&gt;
  &lt;li&gt;Minimal to no configuration.&lt;/li&gt;
  &lt;li&gt;Outstanding reliability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There will be a &lt;strong&gt;rain sensor&lt;/strong&gt; and the &lt;strong&gt;base station&lt;/strong&gt;, both communicating via radio. If it rains, the base station will sound an alarm. That’s it.&lt;/p&gt;

&lt;h2 id=&quot;protoyping-the-rain-sensor&quot;&gt;Protoyping the rain sensor&lt;/h2&gt;

&lt;p&gt;For that, I’m using an Arduino Uno that I already have, along with a rain sensor module that I’ve purchased specifically for this project.&lt;/p&gt;

&lt;p&gt;And here we go with the first prototype:&lt;/p&gt;

&lt;div class=&quot;embed-responsive embed-responsive-16by9&quot;&gt;
&lt;link href=&quot;https://vjs.zencdn.net/8.5.2/video-js.css&quot; rel=&quot;stylesheet&quot; /&gt;
&lt;video id=&quot;my-video&quot; class=&quot;video-js embed-responsive-item&quot; controls=&quot;&quot; loop=&quot;&quot; autoplay=&quot;&quot; preload=&quot;auto&quot; width=&quot;1000&quot; height=&quot;563&quot; poster=&quot;/assets/blog/rafa1/proto-demo-1.jpg&quot; data-setup=&quot;{}&quot;&gt;
&lt;source src=&quot;/assets/blog/rafa1/proto-demo-1.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;p class=&quot;vjs-no-js&quot;&gt;
    To view this video, please enable JavaScript, and consider upgrading to a
    web browser that
    &lt;a href=&quot;https://videojs.com/html5-video-support/&quot; target=&quot;_blank&quot;&gt;supports HTML5 video&lt;/a&gt;
&lt;/p&gt;
&lt;/video&gt;
&lt;script src=&quot;https://vjs.zencdn.net/8.5.2/video.min.js&quot;&gt;&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;h2 id=&quot;schematic&quot;&gt;Schematic&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/rafa1/schematic-rafa1.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The &lt;strong&gt;rain sensor PCB&lt;/strong&gt; (roundish PCB in the video) in connected to the &lt;strong&gt;rain sensor module circuit&lt;/strong&gt;.
The more water on the PCB, the more you reduce its electrical resistance.&lt;/li&gt;
  &lt;li&gt;The &lt;strong&gt;rain sensor module circuit&lt;/strong&gt; uses an &lt;em&gt;Op-Amp (Operational Amplifier) Circuit&lt;/em&gt; LM393 to convert the analog output of the &lt;strong&gt;rain sensor PCB&lt;/strong&gt; to a digital output.
Water on the &lt;strong&gt;rain sensor PCB&lt;/strong&gt; will increase the voltage on the Op-Amp &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Input 5&lt;/code&gt; on this design, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Output 7&lt;/code&gt; will return 0v or 5v depending on the threshold set by the potentiometer &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RV1&lt;/code&gt;. The circuit also provides the original analog output, which allows you to sidestep the digital conversion process entirely.
I purchased this along with the &lt;strong&gt;rain sensor PCB&lt;/strong&gt;, but I’m not sure it’s worth using it. That’s what the prototype is for.&lt;/li&gt;
  &lt;li&gt;The &lt;strong&gt;Arduino Uno&lt;/strong&gt; and surrounding circuitry interfaces with both the digital and analog outputs from the &lt;strong&gt;rain sensor module circuit&lt;/strong&gt;.
The digital signal from the sensor links up with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D8&lt;/code&gt; pin on the Arduino Uno. This pin reads the signal as either HIGH or LOW, triggering the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D4&lt;/code&gt; LED to switch ON or OFF accordingly.
The analog signal feeds into the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;A0&lt;/code&gt; pin on the Arduino Uno. This pin interprets the analog signal as a numerical value ranging from 0 to 1023. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D3&lt;/code&gt; LED has voltage applied to it based on the numerical value, which influences the brightness of the LED.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;take-away&quot;&gt;Take away&lt;/h2&gt;

&lt;p&gt;We’ve learn a few things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It’s fun.&lt;/li&gt;
  &lt;li&gt;Blogging about the prototype triples the amount of work required.&lt;/li&gt;
  &lt;li&gt;The core feature is working: the rain sensor PCB is able to detect rain in a reliable way.&lt;/li&gt;
  &lt;li&gt;The analog output experiences slight fluctuations, especially when the PCB is wet. However, this is not a concern for our use case, as our primary goal is to detect raindrops.&lt;/li&gt;
  &lt;li&gt;The Op-Amp circuit and its digital output appear to be unnecessary, which is good news. This eliminates both the cost and the uncertainty associated with the resistive potentiometer settings. We can simply rebuild the Ohm’s law design with the 10k resistor (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;R1&lt;/code&gt; in the schematic) and handle the threshold via software. We might also need to reintroduce a capacitor (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;C 104&lt;/code&gt; in the schematic) to the rain sensor PCB pins. My suspicion is that it may only be essential for the LM393 Op-Amp circuit.&lt;br /&gt;
I’m all for more software and less hardware 🙃.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;next-step&quot;&gt;Next step&lt;/h2&gt;

&lt;p&gt;We’ll prototype the radio communication between the rain sensor and the base station.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;/blog/2023/10/24/rafa-proto-2&quot;&gt;Part 2 is available here 👋&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;source-code&quot;&gt;Source code&lt;/h2&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/f3517026dd38d2341ea8020c9ea540b9.js&quot;&gt;&lt;/script&gt;</content><author><name></name></author><category term="blog" /><category term="rafa" /><category term="astrophotography" /><category term="DIY" /><category term="electronics" /><summary type="html">So, asking around and searching online, there is no evidence of a ready-to-buy rain alarm that would fit for astrophotography. I’m going to build one.</summary></entry><entry><title type="html">Managing a Plex server with media files hosted on a remote NAS</title><link href="https://jorislacance.fr/blog/2023/08/19/plex-with-nfs" rel="alternate" type="text/html" title="Managing a Plex server with media files hosted on a remote NAS" /><published>2023-08-19T17:04:00-05:00</published><updated>2023-08-19T17:04:00-05:00</updated><id>https://jorislacance.fr/blog/2023/08/19/plex-with-nfs</id><content type="html" xml:base="https://jorislacance.fr/blog/2023/08/19/plex-with-nfs">&lt;p&gt;At home, I’ve got this NAS server taking care of all my media goodies. It’s a Synology DS414j. If you are a connoisseur, you probably already know it consists of 4-bay drive. Released in 2014, and the “j” stands for low-cost. Featuring 512MB of RAM and a dual core CPU clocked at 1.2GHz, it’s sufficient for “typical NAS chores”, even still today. But toss in a &lt;a href=&quot;https://www.plex.tv/&quot;&gt;Plex&lt;/a&gt; server, and it’s a whole different ball game 😁.&lt;/p&gt;

&lt;p&gt;I host the Plex server, among other things, on another machine as a conscientious hobbist. We’re talking about an Intel NUC with an 8th gen i5 CPU and 32GB of RAM. It’s powerful, small, quiet, and easy on the electricity bill. &lt;b&gt;There’s just one minor challenge: The movies &amp;amp; series data remain in the NAS!&lt;/b&gt;&lt;/p&gt;

&lt;h2 id=&quot;mounting-over-nfs&quot;&gt;Mounting over NFS&lt;/h2&gt;

&lt;p&gt;There is absolutely no chance I’m transferring the 4TB volume from the NAS to the NUC server—physically speaking. So I opt for a local network mount, especially since my local network setup boasts a gigabit link between them.&lt;/p&gt;

&lt;p&gt;Swiftly, &lt;b&gt;I configure the NFS sharing permissions in the Synology OS, and write an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fstab&lt;/code&gt; entry to mount the NAS volume on the Plex server host machine.&lt;/b&gt;
And it’s been running smoothly ever since.&lt;/p&gt;

&lt;h2 id=&quot;the-issue&quot;&gt;The issue&lt;/h2&gt;

&lt;p&gt;Well, there’s a hitch in the plan, otherwise, why would I bother writing this blog post?&lt;/p&gt;

&lt;p&gt;&lt;b&gt;This setup falls flat whenever the NUC server reboots. 💩😵&lt;/b&gt;&lt;/p&gt;

&lt;p&gt;Checking the logs, Plex starts before the completion of the NFS mount, causing all movies and series to be marked as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unavailable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/devops1/nok-more.jpg&quot; class=&quot;border img-fluid&quot; /&gt;
&lt;small&gt;I was cleaning my NAS at the same time (Shrek 2 &amp;gt; 1 &amp;gt;&amp;gt; 4 &amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt; 3).&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;It’s kinda fun; Plex actually handles it just fine if the NFS mount drops for a minute and then reconnects. I’ve witnessed it a couple of times! Because I have a WiFi 6 mesh connecting the two devices—it’s not the smartest setup, but I have my reasons that logic can’t explain.&lt;/p&gt;

&lt;p&gt;However, during the boot sequence, Plex just flags the files as unavailable and calls it a day ¯\(ツ)/¯. Sure, the NFS mount completes a few seconds later, but Plex remains oblivious.
Every time the machine reboots—whether it’s me or Ubuntu’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unattended-upgrades&lt;/code&gt; doing it—I have to SSH into the machine and restart Plex to set things right.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/devops1/calm.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;b&gt;At some point, I really wanted to fix this. Let’s walk through a few resolution attempts and how I eventually solved this issue (spoiler: it’s both funny and stupid).&lt;/b&gt;&lt;/p&gt;

&lt;h2 id=&quot;tech-context&quot;&gt;Tech context&lt;/h2&gt;

&lt;p&gt;Let’s cut to the chase, but some details are essential for what follows.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Plex runs via Docker + docker-compose on an Ubuntu host.
    &lt;ul&gt;
      &lt;li&gt;The Docker container’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restart-policy&lt;/code&gt; is set to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unless-stopped&lt;/code&gt;, so it boots right after the systemd &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker.service&lt;/code&gt;.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;The media folder is mounted using both &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fstab&lt;/code&gt; and NFS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;initial-investigation&quot;&gt;Initial investigation&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd&lt;/code&gt; runs the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nfs.mount&lt;/code&gt; service before &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker.service&lt;/code&gt; according to the dependencies and logs. Yet, googling around, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nfs.mount&lt;/code&gt; is not blocking the boot sequence. It better be, because an unreachable remote host could take a minute to timeout. And I confirmed it, booting the machine with the NAS turned off, the boot duration remains unaffected.&lt;/p&gt;

&lt;p&gt;The NFS mount command takes around 1 second. Sometimes faster, and Plex actually manages to boot up just fine—let’s say it happens 1 in 10 times.&lt;/p&gt;

&lt;p&gt;&lt;b&gt;We’ve clearly got ourselves a race condition.&lt;/b&gt;&lt;/p&gt;

&lt;h2 id=&quot;1st-try-delay-the-start-of-the-docker-service&quot;&gt;1st try: Delay the start of the docker service&lt;/h2&gt;

&lt;p&gt;I try to solve the race condition by delaying the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker.service&lt;/code&gt;. I’m fine with this idea.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;nano /lib/systemd/system/docker.service
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Yeah I’m using nano—I don’t take my sysadmin hat too seriously.&lt;/p&gt;

&lt;div class=&quot;language-conf highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;...
[&lt;span class=&quot;n&quot;&gt;Service&lt;/span&gt;]
&lt;span class=&quot;n&quot;&gt;Type&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;notify&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;ExecStart&lt;/span&gt;=/&lt;span class=&quot;n&quot;&gt;usr&lt;/span&gt;/&lt;span class=&quot;n&quot;&gt;bin&lt;/span&gt;/&lt;span class=&quot;n&quot;&gt;dockerd&lt;/span&gt; -&lt;span class=&quot;n&quot;&gt;H&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fd&lt;/span&gt;:// --&lt;span class=&quot;n&quot;&gt;containerd&lt;/span&gt;=/&lt;span class=&quot;n&quot;&gt;run&lt;/span&gt;/&lt;span class=&quot;n&quot;&gt;containerd&lt;/span&gt;/&lt;span class=&quot;n&quot;&gt;containerd&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;sock&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;ExecReload&lt;/span&gt;=/&lt;span class=&quot;n&quot;&gt;bin&lt;/span&gt;/&lt;span class=&quot;n&quot;&gt;kill&lt;/span&gt; -&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HUP&lt;/span&gt; $&lt;span class=&quot;n&quot;&gt;MAINPID&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;TimeoutSec&lt;/span&gt;=&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;RestartSec&lt;/span&gt;=&lt;span class=&quot;m&quot;&gt;2&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;Restart&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;always&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# My attempt to delay the docker service.
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ExecStartPre&lt;/span&gt;=-/&lt;span class=&quot;n&quot;&gt;bin&lt;/span&gt;/&lt;span class=&quot;n&quot;&gt;sleep&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;180&lt;/span&gt;
...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I add a sleep before the execution of the docker service with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecStartPre&lt;/code&gt;, a hefty 180 seconds, just to make sure the change is noticeable.
And this move was ridiculous: I just put a sleep in the boot sequence. Plex starts later, and so does the fstab mounting. However, it seems to show that fstab mounts my volume AFTER the docker service has initialized.&lt;/p&gt;

&lt;h2 id=&quot;2nd-try-tweak-the-systemd-dependencies&quot;&gt;2nd try: Tweak the systemd dependencies&lt;/h2&gt;

&lt;p&gt;If I can’t delay the boot of Plex over the fstab jobs, may I adapt the boot order instead?&lt;/p&gt;

&lt;p&gt;systemd &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;After&lt;/code&gt; option defines the dependencies, that’s exactly what we need.&lt;/p&gt;

&lt;p&gt;So I try to add another dependency following this StackExchange thread &lt;a href=&quot;https://unix.stackexchange.com/questions/246935/set-systemd-service-to-execute-after-fstab-mount&quot;&gt;set-systemd-service-to-execute-after-fstab-mount&lt;/a&gt;.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# This command is used to find out the service name generated out of the fstab entry.&lt;/span&gt;
systemctl list-units &lt;span class=&quot;nt&quot;&gt;--type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;mount | &lt;span class=&quot;nb&quot;&gt;grep &lt;/span&gt;nas
&lt;span class=&quot;c&quot;&gt;#&amp;gt; srv-plex-data-nas-media.mount&lt;/span&gt;
nano /lib/systemd/system/docker.service
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-conf highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[&lt;span class=&quot;n&quot;&gt;Unit&lt;/span&gt;]
&lt;span class=&quot;n&quot;&gt;Description&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;Docker&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Application&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Container&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Engine&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;Documentation&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;https&lt;/span&gt;://&lt;span class=&quot;n&quot;&gt;docs&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;docker&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;com&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;BindsTo&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;containerd&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;service&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;After&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;network&lt;/span&gt;-&lt;span class=&quot;n&quot;&gt;online&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;firewalld&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;service&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;containerd&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;service&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;srv&lt;/span&gt;-&lt;span class=&quot;n&quot;&gt;plex&lt;/span&gt;-&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;-&lt;span class=&quot;n&quot;&gt;nas&lt;/span&gt;-&lt;span class=&quot;n&quot;&gt;media&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;mount&lt;/span&gt; &lt;span class=&quot;c&quot;&gt;# Adding my systemctl `unit` here.
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Wants&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;network&lt;/span&gt;-&lt;span class=&quot;n&quot;&gt;online&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;Requires&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;docker&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;socket&lt;/span&gt;
...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And, I don’t see any improvement 😭. At this point, I shelved the subject for a few months.&lt;/p&gt;

&lt;h2 id=&quot;3rd-try-delay-the-plex-container-boot-from-the-docker-compose&quot;&gt;3rd try: Delay the Plex container boot from the docker-compose&lt;/h2&gt;

&lt;p&gt;I ended up looking at several things to hack around the Docker or docker-compose layers.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/docker/compose/issues/374&quot;&gt;Is there a way to delay container startup to support dependant services with a longer startup time&lt;/a&gt; -&amp;gt; messy, no clear solution.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/ufoscout/docker-compose-wait&quot;&gt;docker-compose-wait&lt;/a&gt; -&amp;gt; nice but not scoped for mount!
    &lt;ul&gt;
      &lt;li&gt;And it has this issue: &lt;a href=&quot;https://github.com/ufoscout/docker-compose-wait/issues/20&quot;&gt;What about wait for existence of a file?&lt;/a&gt;-&amp;gt; damn, that’s what I need, but too lazy to code it. &lt;em&gt;UPDATE: LOL, this was implemented later on, in mid 2021. Did I draft this in early 2021 and only finish writing it two years later? Maybe.&lt;/em&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://marcopeg.com/docker-compose-healthcheck/&quot;&gt;Docker Compose Healthcheck&lt;/a&gt; -&amp;gt; Awesome, &lt;b&gt;as long as everything is dockerized, and my fstab part is not.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;4th-and-final-try-mount-the-nfs-volume-via-docker-compose&quot;&gt;4th and final try: Mount the NFS volume via docker-compose&lt;/h2&gt;

&lt;p&gt;And then, I was like:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/devops1/wat.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Why didn’t this cross my mind? Why don’t &lt;a href=&quot;https://blog.stefandroid.com/2021/03/03/mount-nfs-share-in-docker-compose.html&quot;&gt;I mount the NFS volume via docker-compose&lt;/a&gt;? Could it actually be that easy?&lt;/p&gt;

&lt;p&gt;&lt;b&gt;TLDR: It works perfectly. And having everything wrapped up in a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker-compose.yml&lt;/code&gt; file is a dream come true&lt;/b&gt; because I hate to sysadmin mess with my machines.&lt;/p&gt;</content><author><name></name></author><category term="blog" /><category term="devops" /><category term="Docker" /><category term="Plex" /><summary type="html">At home, I’ve got this NAS server taking care of all my media goodies. It’s a Synology DS414j. If you are a connoisseur, you probably already know it consists of 4-bay drive. Released in 2014, and the “j” stands for low-cost. Featuring 512MB of RAM and a dual core CPU clocked at 1.2GHz, it’s sufficient for “typical NAS chores”, even still today. But toss in a Plex server, and it’s a whole different ball game 😁.</summary></entry><entry><title type="html">Tracking the WhatsApp habits of 5000 random Smartphones</title><link href="https://jorislacance.fr/blog/2021/04/16/whatsapp-tracking-2" rel="alternate" type="text/html" title="Tracking the WhatsApp habits of 5000 random Smartphones" /><published>2021-04-16T14:34:00-05:00</published><updated>2021-04-16T14:34:00-05:00</updated><id>https://jorislacance.fr/blog/2021/04/16/whatsapp-tracking-2</id><content type="html" xml:base="https://jorislacance.fr/blog/2021/04/16/whatsapp-tracking-2">&lt;p&gt;In the &lt;a href=&quot;/blog/2020/04/01/whatsapp-tracking&quot;&gt;previous blog post&lt;/a&gt;, we have seen that this is quite simple to hack the WhatsApp online status of a contact. A simple &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Online&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen yesterday at 19:00&lt;/code&gt; insight can be reverse engineered to leak phone habits at a couple of seconds accuracy.&lt;/p&gt;

&lt;p&gt;There is an even more silly thing not mentioned yet: &lt;strong&gt;You can track any mobile phone !&lt;/strong&gt; So let’s play and &lt;strong&gt;scale to track 5000 random numbers&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Like &lt;a href=&quot;/blog/2020/04/01/whatsapp-tracking&quot;&gt;previously&lt;/a&gt;, I am sharing the &lt;a href=&quot;https://github.com/TomyCesaille/whatsapp-tracking&quot;&gt;source code&lt;/a&gt; as a PROOF OF CONCEPT. You can jump straight to the end if you are more curious about the results than by the technical stuff I’m about to resume. We are reusing the previous code with Node.js, Puppeteer &amp;amp; Grafana.&lt;/p&gt;

&lt;h2 id=&quot;my-friends-my-contacts&quot;&gt;My Friends, My contacts&lt;/h2&gt;

&lt;p&gt;WhatsApp reads the phone contacts and lets you chat with the ones also enrolled.
As a result, we can openly register a random phone as a contact to find him on WhatsApp.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/whatsapp-unknowlist.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;While there is no consent around adding a number to your phone contacts list, WhatsApp protects users by proposing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;consent to reply or report as spam&lt;/code&gt; at the first message interaction. It helps fighting bots for sure.
&lt;strong&gt;Surprisingly, it doesn’t cover the Last Seen status.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/unknownphone-status.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Outch, why am I seeing this &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; data?&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;As seen &lt;a href=&quot;/blog/2020/04/01/whatsapp-tracking&quot;&gt;previously&lt;/a&gt;, there is a privacy setting to prevent this. By default to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Everyone&lt;/code&gt; and nobody configures it.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/whatsapp-privacy-setttings-lastseen.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;WhatsApp disables the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Last Seen&lt;/code&gt; feature in both direction if you don’t want to share yours&lt;/small&gt;&lt;/p&gt;

&lt;h2 id=&quot;playing-around-with-5000-contacts&quot;&gt;Playing around with 5000 contacts&lt;/h2&gt;

&lt;p&gt;Now, I wonder how far we can go with this weakness.
I don’t want to track individuals and won’t ask for the consent of 5000 persons like I could during &lt;a href=&quot;/blog/2020/04/01/whatsapp-tracking&quot;&gt;the first hack&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So I will try to challenge the stuff, compute a couple of anonymous statistics and drop the data. &lt;strong&gt;Let’s scale and track 5000 phones.&lt;/strong&gt;&lt;/p&gt;

&lt;h3 id=&quot;generating-5000-contacts&quot;&gt;Generating 5000 contacts&lt;/h3&gt;

&lt;p&gt;To scale a proof-of-concept with 5000 contacts, I need to register 5000 contacts on my phone! And I won’t do it by hand.&lt;br /&gt;
To do this, I browse to my google account from the desktop website, head to the &lt;strong&gt;Contacts&lt;/strong&gt; page to find a button labeled &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import a CSV&lt;/code&gt; ❤️.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/import-google-contacts.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The documentation looks super messy and too big. I skip it and export my pre-existing contacts to see the data model. And the data model is complex for real. After a couple of tries, I manage to import a couple of contact with the least amount of fields filled possible.&lt;/p&gt;

&lt;div class=&quot;language-csvs highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Given&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Additional&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Family&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Yomi&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Given&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Yomi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Additional&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Yomi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Family&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Yomi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Prefix&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Suffix&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Initials&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Nickname&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Short&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Maiden&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Birthday&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Gender&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Billing&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Information&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Directory&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Server&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Mileage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Occupation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Hobby&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Sensitivity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Priority&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Subject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Notes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Language&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Photo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Membership&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Phone&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Phone&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;Value&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ContactA&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,,,,,,,,,,,,,,,,,,,,,,,,,,,,,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Mobile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;06&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;01&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;02&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;03&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;04&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ContactB&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,,,,,,,,,,,,,,,,,,,,,,,,,,,,,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Mobile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;06&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;01&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;02&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;03&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;05&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ContactC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,,,,,,,,,,,,,,,,,,,,,,,,,,,,,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;Mobile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;06&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;01&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;02&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;03&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;06&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;small&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Additional Name Yomi&lt;/code&gt; ❓&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/import-csv-excel.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;In case you are more used to CSVs in Excel…&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;So I code a script to generate the CSV of 5000 contacts right away⬇️.&lt;/p&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/ce79dc207638b38e9c9dc55c1be75773.js&quot;&gt;&lt;/script&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/import-google-contacts-csv-vscode.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;A bunch of 5000 French numbers&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;And I import it to Gmail.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/5000contacts-imported.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Why the hell this is so easy, Google, are you mad?&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;Finally, I check that my phone syncs them (my poor phone took one entire hour to sync, might not handle 5k more contacts).&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/phone-5000contacts.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;data-acquisition&quot;&gt;Data acquisition&lt;/h3&gt;

&lt;p&gt;I modify my &lt;a href=&quot;https://github.com/TomyCesaille/whatsapp-tracking&quot;&gt;code&lt;/a&gt; made &lt;a href=&quot;/blog/2020/04/01/whatsapp-tracking&quot;&gt;before&lt;/a&gt; &lt;strong&gt;to loop through the imported-unknown 5000 contacts&lt;/strong&gt; and I hit run.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/scraping.gif&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;I leave my crawler for a couple of days (&lt;strong&gt;6 days straight&lt;/strong&gt; with a dumb code! Seriously, there is no security in there). And here is a lovely data viz in the Grafana dashboard reused from the previous work.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/grafana-data.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;With an initial sample of 100 contacts. Each ‘drops to 0’ indicates that the given contact checked his Smartphone&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;We can also &lt;strong&gt;test if a phone number is registered on WhatsApp&lt;/strong&gt; by querying the app, as presented in the screenshot below.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/phone-no-whatsapp.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;I could &lt;strong&gt;manage to keep up scanning 5000 phones during a continuous month&lt;/strong&gt; with an average web scraping code.&lt;/p&gt;

&lt;p&gt;WhatsApp is clearly not checking and preventing abuse of this functionality. I could manage to use &lt;strong&gt;15 000 times&lt;/strong&gt; the search engine to retrieve the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; data &lt;strong&gt;in one single web session&lt;/strong&gt;.&lt;br /&gt;
I scraped a &lt;strong&gt;112k&lt;/strong&gt; records dataset for the study.&lt;/p&gt;

&lt;p&gt;My exploration shows we can:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Find out if a phone number is(was) registered on WhatsApp
    &lt;ul&gt;
      &lt;li&gt;If a phone number is(was) valid and assigned in the telephony network&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;Retrieve the user WhatsApp profile picture (a big format version is extractable)&lt;/li&gt;
  &lt;li&gt;Retrieve the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; data itself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the 5000 contacts, it is essential to point out that I don’t know how many numbers are valid;&lt;br /&gt;
I used the French &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;06xxxxxxxx&lt;/code&gt; range (or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;00336xxxxxxxx&lt;/code&gt; www) which came into saturation 10 years ago (So was introduced the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;07xxxxxxxx&lt;/code&gt; range at that time). We could maybe suppose at least 80% of valid phone numbers.&lt;/p&gt;

&lt;p&gt;Here is a viz of users grouped by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; date to give a sight about WhatsApp usage. I gathered the data around the first week of February.&lt;/p&gt;

&lt;script type=&quot;text/javascript&quot; src=&quot;https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js&quot;&gt;&lt;/script&gt;

&lt;div id=&quot;chart1&quot; style=&quot;height: 300px;max-width: 800px&quot;&gt;&lt;/div&gt;
&lt;script type=&quot;text/javascript&quot;&gt;
    var myChart = echarts.init(document.getElementById(&quot;chart1&quot;));
    var option;
    option = {
        title: {
        text: 'WhatsApp - the status of 5000 random French numbers',
        left: 'left',
        top: 'bottom'
    },
    tooltip: {
        trigger: 'item',
        formatter: '{b} : {c}'
    },
    series: [
        {
            type: 'funnel',
            width: '40%',
            height: '85%',
            left: '5%',
            top: '5%',
            sort: 'ascending',
            funnelAlign: 'left',
            emphasis: {
                label: {
                    fontSize: 18
                }
            },
            data: [
                {value: 179, name: '179 Active in the last 24h'},
                {value: 281, name: '281 Active in the last week'},
                {value: 1253, name: '1253 Active in the last month'},
                {value: 1357, name: '1357 Active in the last year'},
                {value: 1363, name: '1363 `Last seen` data available'},
                {value: 1751, name: '1751 Registered in WhatsApp'},
                {value: 4000, name: '4000 Real existing phone numbers estimation?'},
                {value: 5000, name: '5000 random French numbers'}
            ]
        }
    ]
    };
    if (option &amp;&amp; typeof option === 'object') {
    myChart.setOption(option);
}
&lt;/script&gt;

&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;p&gt;The drop from &lt;strong&gt;1357&lt;/strong&gt; active users during &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;the last month&lt;/code&gt; to &lt;strong&gt;281&lt;/strong&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;the last week&lt;/code&gt; is quite significant and could be linked to the shit storm that WhatsApp had during late December 2020.
I’m somewhat not inspired to mine the data further. I guess this is it. Thanks for reading.&lt;/p&gt;

&lt;h2 id=&quot;messenger-signal--telegram-bonus&quot;&gt;Messenger, Signal &amp;amp; Telegram (bonus)&lt;/h2&gt;

&lt;p&gt;(Facebook) &lt;strong&gt;Messenger&lt;/strong&gt; doesn’t use phone contact numbers to find users as they rely on Facebook accounts. A completely different design as it is a social network and less of a mobile VOIP service. I expect many similar hacks and data acquisition to do around Messenger. But that’s another case, for later?&lt;/p&gt;

&lt;p&gt;Signal and Telegram are more similar to WhatsApp in their way of handling contacts. However, they don’t leak so much.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Telegram&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; data is less available publicly and is vague like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;within a month&lt;/code&gt;. The search engine was not leaking to estimate the number of users during my study. But it seems this is now possible as I am writing this six weeks later. I’m pissed off a little bit.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;Signal&lt;/strong&gt;, there is no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; functionality at all; This is clean. But counting the users was simple.
Signal features a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{x} is on Signal!&lt;/code&gt; notification when a contact has joined. It helps to monitor users’ conversion to signal.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/signal-newusers.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;94 users out of 5000 were on Signal at day 1&lt;/strong&gt;, and 1-2 newcomers every day during the following days. Nobody uses Signal in France, &lt;a href=&quot;https://twitter.com/elonmusk/status/1347165127036977153&quot;&gt;Elon Musk had a reduced influence&lt;/a&gt; apparently.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack2/usesignalelon.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Yes, it’s a picture. Trackers free&lt;/small&gt;&lt;/p&gt;</content><author><name></name></author><category term="blog" /><category term="hacking" /><category term="web scraping" /><summary type="html">In the previous blog post, we have seen that this is quite simple to hack the WhatsApp online status of a contact. A simple Online or last seen yesterday at 19:00 insight can be reverse engineered to leak phone habits at a couple of seconds accuracy.</summary></entry><entry><title type="html">3D print of a Bahtinov filter</title><link href="https://jorislacance.fr/blog/2021/01/14/bahtinov" rel="alternate" type="text/html" title="3D print of a Bahtinov filter" /><published>2021-01-14T15:57:00-06:00</published><updated>2021-01-14T15:57:00-06:00</updated><id>https://jorislacance.fr/blog/2021/01/14/bahtinov</id><content type="html" xml:base="https://jorislacance.fr/blog/2021/01/14/bahtinov">&lt;h2 id=&quot;optical-focus&quot;&gt;Optical Focus&lt;/h2&gt;

&lt;p&gt;When you take photos, you need to focus.&lt;br /&gt;
So when I go out the night to do astrophotography, I also need to focus 🙃.&lt;/p&gt;

&lt;p&gt;No need to say, my collected data is shit when I don’t do it properly. Shooting stars do not give room for mistakes, even tiny ones.&lt;/p&gt;

&lt;p&gt;And I struggle so hard to focus with manual lenses for astro (the autofocus does not work in such darky situations! Thus, I shoot with 60s-90s lenses from the URSS with a 2015 camera; So no autofocus anyway).&lt;/p&gt;

&lt;p&gt;One would say&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“Leave the old stuff, buy a real tube, equatorial mount &amp;amp; motorized focuser stuff”.
–&lt;cite&gt;Michmich&lt;/cite&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Yet, I’m not considering switching my 5kg backpack for a 25kg rolling case.&lt;/p&gt;

&lt;p&gt;And this night, I discovered the Bahtinov mask!&lt;/p&gt;

&lt;h2 id=&quot;bahtinov&quot;&gt;Bahtinov&lt;/h2&gt;

&lt;p&gt;The &lt;a href=&quot;https://en.wikipedia.org/wiki/Bahtinov_mask&quot;&gt;Bahtinov mask&lt;/a&gt; creates a diffraction pattern when put in front of a lens.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/bahtinov/Simulation_of_a_bahtinov_mask_diffraction_pattern_when_focusing.gif&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Source: Wikipedia&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;The super awesome thing about it is that the visual artifact tells you which direction you need to roll the focus ring to sharpen the picture.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/bahtinov/bahtinov-concept.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;I want one.&lt;/p&gt;

&lt;h2 id=&quot;making-one&quot;&gt;Making one&lt;/h2&gt;

&lt;p&gt;Pain in the ass; The filter is tricky. It mainly depends on the camera focal length. And it would be cool to have the correct diameter for lenses.&lt;/p&gt;

&lt;p&gt;Won’t buy one, time to 3D print mine!&lt;/p&gt;

&lt;p&gt;I love the open-source world. My 3D printer is fully open source. The Bahtinov might be.
My first exploration led me straight to the marvelous &lt;a href=&quot;https://www.thingiverse.com&quot;&gt;thingiverse&lt;/a&gt;.
And especially here: &lt;a href=&quot;https://www.thingiverse.com/thing:2168672&quot;&gt;Bahtinov Mask For Samyang 135mm Lens&lt;/a&gt; ⬇️.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/bahtinov/thingiverse-1.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;And just like that, I downloaded &lt;a href=&quot;https://www.openscad.org/&quot;&gt;Openscad&lt;/a&gt;, forked the project &amp;amp; tweaked it.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/bahtinov/openscad-1.png&quot; class=&quot;border img-fluid&quot; /&gt;
&lt;small&gt;OpenSCAD, the source code and the rendering&lt;/small&gt;&lt;/p&gt;

&lt;h2 id=&quot;3d-printing&quot;&gt;3D Printing&lt;/h2&gt;

&lt;p&gt;Like always, I struggle so hard to scale my prints when size matter (what are you thinking?).
So I first print the object upside down to print the ring first, kill the print, check the diameter with my lens &amp;amp; adjust.&lt;/p&gt;

&lt;p&gt;Took me six shots to get the expected inner diameter. Does my printer suck?&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/bahtinov/DSC07802.jpg&quot; class=&quot;border img-fluid col-lg-10&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/bahtinov/DSC08225.jpg&quot; class=&quot;border img-fluid col-lg-6&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Asked for 75mm, got 74.65m. This is too tight 😐&lt;/small&gt;&lt;/p&gt;

&lt;div class=&quot;embed-responsive embed-responsive-16by9&quot;&gt;
&lt;link href=&quot;https://vjs.zencdn.net/8.5.2/video-js.css&quot; rel=&quot;stylesheet&quot; /&gt;
&lt;video id=&quot;my-video&quot; class=&quot;video-js embed-responsive-item&quot; controls=&quot;&quot; preload=&quot;auto&quot; width=&quot;1000&quot; height=&quot;563&quot; poster=&quot;/assets/blog/bahtinov/DSC07796.jpg&quot; data-setup=&quot;{}&quot;&gt;
&lt;source src=&quot;/assets/blog/bahtinov/printing-record.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;p class=&quot;vjs-no-js&quot;&gt;
    To view this video, please enable JavaScript, and consider upgrading to a
    web browser that
    &lt;a href=&quot;https://videojs.com/html5-video-support/&quot; target=&quot;_blank&quot;&gt;supports HTML5 video&lt;/a&gt;
&lt;/p&gt;
&lt;/video&gt;
&lt;script src=&quot;https://vjs.zencdn.net/8.5.2/video.min.js&quot;&gt;&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;h2 id=&quot;closing&quot;&gt;Closing&lt;/h2&gt;

&lt;p&gt;I made my DIY Bahtinov mask in 4 hours, almost including this blog writing (nope at all).&lt;br /&gt;
&lt;strong&gt;OpenSCAD&lt;/strong&gt; kinda blew my mind: code, variable, versioning; that’s promising! Blender will stick to arts.&lt;/p&gt;

&lt;p&gt;I own a coffee to &lt;a href=&quot;https://github.com/dakloifarwa&quot;&gt;dakloifarwa&lt;/a&gt; for the openSCAD source code!&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/bahtinov/DSC08217.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;
&lt;div class=&quot;text-center row&quot;&gt;
    &lt;div class=&quot;col-lg-6&quot;&gt;
        &lt;img src=&quot;/assets/blog/bahtinov/DSC08215.jpg&quot; class=&quot;border img-fluid&quot; /&gt;
    &lt;/div&gt;
    &lt;div class=&quot;col-lg-6&quot;&gt;
        &lt;img src=&quot;/assets/blog/bahtinov/DSC08224.jpg&quot; class=&quot;border img-fluid&quot; /&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;img src=&quot;/assets/blog/bahtinov/result.gif&quot; class=&quot;border img-fluid&quot; /&gt;
&lt;small&gt;Hold on, this is an animated gif&lt;/small&gt;&lt;/p&gt;

&lt;h2 id=&quot;you-are-still-here-bonus&quot;&gt;You are still here? Bonus!&lt;/h2&gt;

&lt;p&gt;Let’s code my first real &lt;strong&gt;OpenSCAD&lt;/strong&gt; code -&amp;gt; I need a lens cap!&lt;/p&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/9742571276409d0b1d16db2115f17f21.js&quot;&gt;&lt;/script&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/bahtinov/openscad-2.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;I printed it with wood, so fancy.&lt;br /&gt;
Reach this print at &lt;a href=&quot;https://www.thingiverse.com/thing:4728481&quot;&gt;thingiverse&lt;/a&gt;.&lt;/p&gt;

&lt;div class=&quot;text-center row&quot;&gt;
    &lt;div class=&quot;col-lg-6&quot;&gt;
        &lt;img src=&quot;/assets/blog/bahtinov/DSC08221.jpg&quot; class=&quot;border img-fluid&quot; /&gt;
    &lt;/div&gt;
    &lt;div class=&quot;col-lg-6&quot;&gt;
        &lt;img src=&quot;/assets/blog/bahtinov/DSC08218.jpg&quot; class=&quot;border img-fluid&quot; /&gt;
    &lt;/div&gt;
&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;</content><author><name></name></author><category term="blog" /><category term="3D print" /><category term="astrophotography" /><category term="OpenSCAD" /><category term="DIY" /><summary type="html">Optical Focus</summary></entry><entry><title type="html">Hack the WhatsApp status to track contacts</title><link href="https://jorislacance.fr/blog/2020/04/01/whatsapp-tracking" rel="alternate" type="text/html" title="Hack the WhatsApp status to track contacts" /><published>2020-04-01T12:00:00-05:00</published><updated>2020-04-01T12:00:00-05:00</updated><id>https://jorislacance.fr/blog/2020/04/01/whatsapp-tracking</id><content type="html" xml:base="https://jorislacance.fr/blog/2020/04/01/whatsapp-tracking">&lt;p&gt;WhatsApp shares your contacts’ status with you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR: You can protect yourself from this hack by changing your account privacy settings. By default, WhatsApp shares your status with others.&lt;br /&gt;
Since nobody is changing settings nowadays, this hack works almost all the time.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;DISCLAIMER: This is a proof of concept to raise awareness and a bit of a technical challenge before anything else.
Don't use my source code provided to track someone, don't be a dick ❤️.&lt;/h4&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/0-whatsappandroid-lastseendate.jpg&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;WhatsApp in Android&lt;/small&gt;&lt;/p&gt;

&lt;h2 id=&quot;exploit-the-feature&quot;&gt;Exploit the feature&lt;/h2&gt;

&lt;p&gt;I want to exploit this feature to track users (for science). My first question is: How does this feature works?&lt;/p&gt;

&lt;p&gt;To make things up, I am using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://web.whatsapp.com/&lt;/code&gt; in my laptop web browser instead of my Android Smartphone. So I’ll deal with regular web reverse engineering to get this exploit done. I skip Android app reverse engineering for another time.&lt;/p&gt;

&lt;p&gt;I pick a friend of mine’s phone and look at how his status behaves on my side.&lt;/p&gt;

&lt;p&gt;Initially, the status is offline, and in this case, WhatsApp gives you an absolute date, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen 16/03/2020 at 15:40&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/1-whatsappweb-olddatelastseen.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;I’m unlocking the friend’s phone and opening an app (not WhatsApp), doing that for a minute, nothing on my side.&lt;br /&gt;
Ok; Now switching to WhatsApp. The status has changed to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;online&lt;/code&gt; 10 seconds later. I didn’t go to the conversations I am sharing with this phone/contact to verify that the status transmits without this condition.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/2-whatsappweb-onlinestatus.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;online&lt;/code&gt; status remains until you leave WhatsApp or shut off the screen on the targeted phone.&lt;br /&gt;
And then, it reverts to a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; date once offline again.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/3-whatsappweb-newdatelastseen.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;So to summarize the thing:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We won’t be able to track someone using his phone globally (&lt;strong&gt;hopefully!&lt;/strong&gt;)&lt;/li&gt;
  &lt;li&gt;But we can track the WhatsApp usage of anyone we have in our contacts&lt;/li&gt;
  &lt;li&gt;The info leaked are &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; date and the live &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;online&lt;/code&gt; status per contact&lt;/li&gt;
  &lt;li&gt;We can expect to have at least a minute of accuracy for the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; date&lt;/li&gt;
  &lt;li&gt;And the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;online&lt;/code&gt; status shows up once WhatsApp is in the foreground for at least 5-10 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;technical-analysis&quot;&gt;Technical analysis&lt;/h2&gt;

&lt;p&gt;I’m opening the Firefox debugger (Proudly using Firefox again !) to see how the front of WhatsApp web is fetching the coveted data.&lt;/p&gt;

&lt;p&gt;The front uses a web socket communication to gather the data in real-time, somewhat every 10-15 seconds.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/4-firefoxdebugger-websocket.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;If we look carefully, the front seems to poke the server every ~15 seconds with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?,,&lt;/code&gt; and most of the time follows a reply of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;!{timestamp}&lt;/code&gt;. A kind of keep-alive stuff. Not interesting for us.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/5-firefoxdebugger-pingpong.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The server pushes another kind of message to the front when the status of the contact changes.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/6-firefoxdebugger-in.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;img src=&quot;/assets/blog/whatsapphack/6-firefoxdebugger-out.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; value I partially cover in black is the phone number, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;type&lt;/code&gt; is the available/unavailable flag, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t&lt;/code&gt; is the timestamp of our &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; date. The whole payload is encapsulated in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Presence&lt;/code&gt; object, easy to recognize.&lt;br /&gt;
The timestamp is matching what we read in the UI.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/6-timestampverify.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;using https://www.epochconverter.com/&lt;/small&gt;&lt;/p&gt;

&lt;h2 id=&quot;limitations&quot;&gt;Limitations&lt;/h2&gt;

&lt;p&gt;To receive the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;presence&lt;/code&gt; events from the server via the web socket com, we (the front) subscribe to a specific phone number (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt;). It is triggered when we select another conversation/contact with the web interface.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/7-firefoxdebugger-subscribe.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;So, in this conception, we only receive the active contact’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;presence&lt;/code&gt; events.
In other words, &lt;strong&gt;we can only track one contact at a time in the web socket connection&lt;/strong&gt;. Too bad for us!&lt;/p&gt;

&lt;p&gt;WhatsApp also prevents us from opening several concurrent instances (of the same cookies). So we can’t open two web-socket channels altogether. It would have been too easy!&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/8-whatsappweb-singletonsession.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;And finally, this one-WhatsApp-web-session-at-a-time behavior still applies when trying two independent sessions (not the same cookies).
A new session will trigger the older one to close, and especially at the web-socket layer.&lt;/p&gt;

&lt;p&gt;Another expected limitation, the validity of our session is limited in time. Mine will expire the 22/10/2020, in 6 months+. It’s odd to retrieve this info on the front side like this. I might be misinterpreting this one.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/8-firefoxdebugger-expiration.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;naïve-implementation&quot;&gt;Naïve implementation&lt;/h2&gt;

&lt;p&gt;Now that we’ve defined what is the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; feature of WhatsApp and how it could be misused to track users, it is time to code something.
We also looked at the technical implementation and for a possible easy-security-flaw.&lt;/p&gt;

&lt;p&gt;We could re-code the web socket communication exchange to retrieve the status data, but this will be complex. Too complex if we can only track one contact at a time.
I will start with high-level techno and accept the current known limitations and see where it goes.&lt;/p&gt;

&lt;p&gt;My idea is to see where we can go with cheap hacking work before doing advanced things.&lt;/p&gt;

&lt;p&gt;I’ll decompose the proof of concept into three steps:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Gather the data&lt;/li&gt;
  &lt;li&gt;Store the data (easy)&lt;/li&gt;
  &lt;li&gt;Visualize the data (easy, but challengy for me)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’ll scrape the data using Node.js and Puppeteer; Puppeteer allows us to control a browser and interact the same way a user would do with the mouse and keyboard. It avoids doing complex reverse engineering at the web socket level, and that’s why I picked that up. I’m more used to Selenium + C#. This is my first puppeteer experiment, so be kind to me.&lt;/p&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/3cd806ac93d1dfef2ce1ce4204519085.js&quot;&gt;&lt;/script&gt;

&lt;p&gt;We got the core stuff in 38 lines of code.&lt;/p&gt;

&lt;p&gt;To continue, we need to parse the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen today at 13:15&lt;/code&gt; format into a proper date format.
To do that, I’m using the so-wonderful &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chrono-node&lt;/code&gt; npm package.&lt;/p&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/c6037e1e9cd99a9b53bbb6863b61a5a0.js&quot;&gt;&lt;/script&gt;

&lt;p&gt;Finally, I implement a loop in the code to scan the status constantly and store it into InfluxDB 2.0.&lt;br /&gt;
InfluxDB is a time-series database. That’s perfect for our use case.&lt;/p&gt;

&lt;p&gt;I will derive the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; date into an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;offline since&lt;/code&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UInteger&lt;/code&gt;. It will be the seconds counter since the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;last seen&lt;/code&gt; date.&lt;br /&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;offline since&lt;/code&gt; will value &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0&lt;/code&gt; when the status is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;online&lt;/code&gt;.&lt;br /&gt;
&lt;strong&gt;Deriving our data is turning our event-based data into time-series data.&lt;/strong&gt;
This design fits better for InfluxDB and especially for Grafana whose will display our data. And that’s stateless; I like that.&lt;/p&gt;

&lt;p&gt;To store the data into InfluxDB 2.0, I’m using the Node.js client with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;line protocol&lt;/code&gt; format of InfluxDB.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;measurementName,tagKey=tagValue fieldKey=&quot;fieldValue&quot; 1465839830100400200
--------------- --------------- --------------------- -------------------
       |               |                  |                    |
  Measurement       Tag set           Field set            Timestamp
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The data stored looks like this:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;status,contactName=Toto offlineSince=8275u 1465839830100400200
status,contactName=Toto offlineSince=8280u 1465839830100400200
status,contactName=Toto offlineSince=0u 1465839830100400200
status,contactName=Tata offlineSince=0u 1465839830100400200
------ ---------------  ----------------- -------------------
  |            |                |                  |
Measurement Tag set         Field set          Timestamp
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The code implementation:&lt;/p&gt;

&lt;script src=&quot;https://gist.github.com/TomyCesaille/ef43b38411873990db64e45dcc36c7c8.js&quot;&gt;&lt;/script&gt;

&lt;p&gt;There is an edge case I want to handle: Sometimes, the status does not display at all in WhatsApp.&lt;br /&gt;
In this case, we won’t enter a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;offlineSince&lt;/code&gt; measure into the database because we don’t have one.
Instead, we will log a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;statusAvailable&lt;/code&gt; measure (being &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1&lt;/code&gt;) each time we scan the status.&lt;/p&gt;

&lt;p&gt;We now connect Grafana to InfluxDB and create a dashboard to monitor our acquisition. And voilà, let it run.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/whatsapphack/9-grafanadashboard.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;You can find the source code of this proof of concept &lt;a href=&quot;https://github.com/TomyCesaille/whatsapp-tracking&quot;&gt;here&lt;/a&gt;.&lt;br /&gt;
We will try to improve this hack later, for another blog entry someday!&lt;/p&gt;

&lt;h2 id=&quot;update&quot;&gt;Update&lt;/h2&gt;

&lt;p&gt;You can found &lt;a href=&quot;https://jorislacance.fr/blog/2021/04/16/whatsapp-tracking-2&quot;&gt;the next episode here&lt;/a&gt; in which we scale this work to track 5000 Smartphones.&lt;/p&gt;</content><author><name></name></author><category term="blog" /><category term="hacking" /><category term="web scraping" /><summary type="html">WhatsApp shares your contacts’ status with you.</summary></entry><entry><title type="html">Shiny Tiny RPG - Game Dev using Shiny Stats</title><link href="https://jorislacance.fr/blog/2020/01/12/shiny-tiny-rpg" rel="alternate" type="text/html" title="Shiny Tiny RPG - Game Dev using Shiny Stats" /><published>2020-01-12T14:30:00-06:00</published><updated>2020-01-12T14:30:00-06:00</updated><id>https://jorislacance.fr/blog/2020/01/12/shiny-tiny-rpg</id><content type="html" xml:base="https://jorislacance.fr/blog/2020/01/12/shiny-tiny-rpg">&lt;h2 id=&quot;intro&quot;&gt;Intro&lt;/h2&gt;

&lt;p&gt;The development steps for &lt;a href=&quot;/projects/shiny-stats&quot;&gt;Shiny Stats&lt;/a&gt;, my Unity Asset product, are nearly over.&lt;br /&gt;
After roughly 2 years of work, quite not regular though, there are still plenty of things to do such as documentation and… &lt;strong&gt;marketing&lt;/strong&gt; !&lt;/p&gt;

&lt;p&gt;Marketing today will be about making a game demonstrator to show off Shiny Stats awesomeness. Let’s go.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/CoverImage1950x1300.png&quot; title=&quot;Shiny Stats&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;demo-game&quot;&gt;Demo game&lt;/h2&gt;

&lt;p&gt;Having a playable demo to convince developers to adopt (to buy!) Shiny Stats will definitively help. The older-version-of-me didn’t see this coming back in 2017 when I did the initial planning. I told myself that screenshots with a lot of data charts would do the job as communication material. This was very pragmatic, but I realize today that this will not advertise Shiny Stats the right way. Shiny Stats is not about mathematics, it’s about improving game developers productivity so they can focus on what matters.&lt;/p&gt;

&lt;p&gt;Shiny Stats aims to give game makers a ready-to-use utility to add stats and leveling mechanisms into their games. It is a wide subject, a complex one, and a lot of game uses these mechanisms. This is the kind of asset I would have bought while making &lt;a href=&quot;/projects/caatacombb&quot;&gt;Caatacombb&lt;/a&gt; in 2015 because this was very long to implement from scratch.&lt;/p&gt;

&lt;p&gt;My demo should showcase a mini RPG example because this genre covers all the Shiny Stats possibilities thus this is my key audience I want to focus on. And I’ll have to be careful with the communication to make it clear that the Unity Asset also fits for non-RPG games, such as Tower Defense games for instance.&lt;/p&gt;

&lt;p&gt;This game demo will be shipped with the Shiny Stats Unity Asset and I don’t want to bother game developers with a heavy demo. So I won’t reuse any official Unity demo code and assets. Unity’s demos are also a bit over complex for my need.
That is exactly why I can’t also reuse my Caatacombb game assets either! &lt;strong&gt;So we’ll kinda make the game from scratch.&lt;/strong&gt;&lt;/p&gt;

&lt;h2 id=&quot;making-a-game&quot;&gt;Making a Game&lt;/h2&gt;

&lt;h3 id=&quot;name-it&quot;&gt;Name it&lt;/h3&gt;

&lt;p&gt;Ok, we are about to make a game here. Almost there.
First, let’s pick a name for this demo-game: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Shiny Tiny RPG&lt;/code&gt;.&lt;br /&gt;
That’s cute, it reminds Shiny Stats, and has the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RPG&lt;/code&gt; tag in it, this will be our tiny RPG game featuring Shiny Stats.&lt;/p&gt;

&lt;h3 id=&quot;game-design&quot;&gt;Game Design&lt;/h3&gt;

&lt;blockquote&gt;
  &lt;p&gt;Minimalist as fuck. A bit like &lt;em&gt;Realm of the Mad God&lt;/em&gt; but with 3D tech.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We will follow that quote for the whole game conception.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/rotmg.png&quot; title=&quot;Realm of the Mad God&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Realm of the Mad God&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;Few good things to remember:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I am willing to spend 30 hours of work max
    &lt;ul&gt;
      &lt;li&gt;And have fun making it&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;The game demo
    &lt;ul&gt;
      &lt;li&gt;Cover all Shiny Stats features&lt;/li&gt;
      &lt;li&gt;Is interesting to play&lt;/li&gt;
      &lt;li&gt;Reusable for marketing &amp;amp; documentation&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some Technical requirements:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;As small as possible since it will be shipped with Shiny Stats
    &lt;ul&gt;
      &lt;li&gt;No external dependencies&lt;/li&gt;
      &lt;li&gt;Reduced use of binaries (Will I need textures?)&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;Scripts code must be naïve, beginner-friendly and not optimized to enhance readability&lt;/li&gt;
  &lt;li&gt;Running &amp;amp; playable in WebGL&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;shiny-stats-configuration&quot;&gt;Shiny Stats configuration&lt;/h3&gt;

&lt;p&gt;Wooh, we start the game conception with the Shiny Stats config.&lt;br /&gt;
I am cherry picking a bit into &lt;em&gt;World Of Warcraft&lt;/em&gt;’s vocabulary since it was one of the most played MMORPG game ever.&lt;/p&gt;

&lt;p&gt;Attributes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Stamina&lt;/li&gt;
  &lt;li&gt;Strength&lt;/li&gt;
  &lt;li&gt;Intellect&lt;/li&gt;
  &lt;li&gt;Agility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Classes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Warrior&lt;/li&gt;
  &lt;li&gt;Paladin&lt;/li&gt;
  &lt;li&gt;Rogue&lt;/li&gt;
  &lt;li&gt;Wizard&lt;/li&gt;
  &lt;li&gt;Cleric&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stats:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Health Points&lt;/li&gt;
  &lt;li&gt;Mana Points&lt;/li&gt;
  &lt;li&gt;Attack Power&lt;/li&gt;
  &lt;li&gt;Spell Power&lt;/li&gt;
  &lt;li&gt;Attack Speed&lt;/li&gt;
  &lt;li&gt;Walking Speed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Extra Stats:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Experience - Amount of experience points to gather in order to level up&lt;/li&gt;
  &lt;li&gt;Experience Drop - Amount of experience points to drop on entity death&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Level and Experience:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Level 1 to 20&lt;/li&gt;
  &lt;li&gt;Use the built-in Experience System of Shiny Stats&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;meta&quot;&gt;Meta&lt;/h4&gt;

&lt;table class=&quot;table table-striped table-sm table-bordered&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Stat&lt;/th&gt;
      &lt;th&gt;Affected by (Attribute)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Health Points&lt;/td&gt;
      &lt;td&gt;Stamina&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Mana Points&lt;/td&gt;
      &lt;td&gt;Intellect&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Attack Power&lt;/td&gt;
      &lt;td&gt;Strength&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Spell Power&lt;/td&gt;
      &lt;td&gt;Intellect&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Attack Speed&lt;/td&gt;
      &lt;td&gt;Agility&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Walking Speed&lt;/td&gt;
      &lt;td&gt;Agility&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;small&gt;Stats-Attributes interactions&lt;/small&gt;&lt;/p&gt;

&lt;table class=&quot;table table-striped table-sm table-bordered&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Class&lt;/th&gt;
      &lt;th&gt;Health Points (Stamina)&lt;/th&gt;
      &lt;th&gt;Mana Points (Intellect)&lt;/th&gt;
      &lt;th&gt;Attack Power (Strength)&lt;/th&gt;
      &lt;th&gt;Spell Power (Intellect)&lt;/th&gt;
      &lt;th&gt;Attack Speed (Agility)&lt;/th&gt;
      &lt;th&gt;Walking Speed (Agility)&lt;/th&gt;
      &lt;th&gt;&lt;em&gt;Sum&lt;/em&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Warrior&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;10&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Paladin&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;11&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Rogue&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;12&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Wizard&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;9&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Cleric&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;+++&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;++&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;+&lt;/td&gt;
      &lt;td&gt;10&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;small&gt;Class balance idea&lt;/small&gt;&lt;/p&gt;

&lt;h3 id=&quot;gameplay&quot;&gt;Gameplay&lt;/h3&gt;

&lt;p&gt;We will set up a small map where live some enemies to defeat and one ultimate boss.
The game idea is that the player has to climb levels by killing enemies to improve its power in order to be strong enough to defeat the boss.&lt;br /&gt;
The enemies will be scattered on the map, from level 1 to 19. The boss will be level 20.&lt;/p&gt;

&lt;p&gt;The player starts the game by choosing his class. He can change his class whenever he wants, without loosing his progression - Not typical, but this is convenient for the demo purpose.&lt;/p&gt;

&lt;p&gt;Character movements will be with the keyboard arrows (WASD).&lt;/p&gt;

&lt;p&gt;There are two type of attack:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;regular hit&lt;/code&gt; - Gives physical damage (using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;attack power&lt;/code&gt; stat). Trigger with mouse LEFT click&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;special skill&lt;/code&gt; - Depends on the class, and cost mana. Trigger with mouse RIGHT click&lt;/li&gt;
&lt;/ul&gt;

&lt;table class=&quot;table table-striped table-sm table-bordered&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Class&lt;/th&gt;
      &lt;th&gt;Skill&lt;/th&gt;
      &lt;th&gt;Description&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;All&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Attack&lt;/td&gt;
      &lt;td&gt;Regular attack using Attack Power&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Warrior&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Fatal Strike&lt;/td&gt;
      &lt;td&gt;150% regular attack damage&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Paladin&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Holy Strike&lt;/td&gt;
      &lt;td&gt;100% regular attack damage, the damage dealt are returned as Health Points&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Rogue&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Backstab&lt;/td&gt;
      &lt;td&gt;75% chance to deal 100% regular attack damage, 25% chance to deal 500% regular attack damage&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Wizard&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Flame Strike&lt;/td&gt;
      &lt;td&gt;Use Spell Power instead of Attack Power to deal damage&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Cleric&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Self Heal&lt;/td&gt;
      &lt;td&gt;Use Spell Power to heal himself, no damage dealt to enemies&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;time-to-dev&quot;&gt;Time to dev&lt;/h2&gt;

&lt;h3 id=&quot;map&quot;&gt;Map&lt;/h3&gt;

&lt;p&gt;I’ve setup two materials for the environment, one for the floor and one for the walls.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/floor-material.png&quot; title=&quot;floor material&quot; class=&quot;border img-fluid&quot; /&gt;
&lt;img src=&quot;/assets/blog/shinytinyrpg/wall-material.png&quot; title=&quot;wall material&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;To create the map, we only use &lt;em&gt;Cube&lt;/em&gt; meshes. We use a 1x1x1 cube as a reference to scale the map properly.
In our need, the map will be quite big to put a lot of empty space between the enemies, with walls to limit the zone and guide the player. It will look like a closed dungeon, in a cheap way.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/map-building.gif&quot; title=&quot;map building&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;/p&gt;

&lt;p&gt;That’s already during this map conception that we need to define the enemies’ level distribution inside the map and the inner combat challenge. So we need to define the meta now, at least on paper.&lt;/p&gt;

&lt;table class=&quot;table table-striped table-sm table-bordered&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Difficulty to defeat&lt;/th&gt;
      &lt;th&gt;Enemy lvl 1-4&lt;/th&gt;
      &lt;th&gt;Enemy lvl 5-9&lt;/th&gt;
      &lt;th&gt;Enemy lvl 10-14&lt;/th&gt;
      &lt;th&gt;Enemy lvl 15-19&lt;/th&gt;
      &lt;th&gt;Boss (lvl 20)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Player level 1-4&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;medium&lt;/td&gt;
      &lt;td&gt;hard&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Player level 5-9&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;easy&lt;/td&gt;
      &lt;td&gt;medium&lt;/td&gt;
      &lt;td&gt;hard&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Player level 10-14&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;easy&lt;/td&gt;
      &lt;td&gt;medium&lt;/td&gt;
      &lt;td&gt;hard&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Player level 15-19&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;easy&lt;/td&gt;
      &lt;td&gt;medium&lt;/td&gt;
      &lt;td&gt;hard&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Player level 20&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;easy&lt;/td&gt;
      &lt;td&gt;medium&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/map-monsters.png&quot; title=&quot;map enemies&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Map conception with enemies placement&lt;/small&gt;&lt;/p&gt;

&lt;h3 id=&quot;characters&quot;&gt;Characters&lt;/h3&gt;

&lt;p&gt;Similar to the map, we will only use primary meshes mixed together instead of doing ‘real’ 3D models.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/layout-character.png&quot; title=&quot;map enemies&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Character modeling using 1 sphere &amp;amp; 2 cylinders&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;We create 6 characters: 5 playable classes (player and enemies) and one boss.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/characters.png&quot; title=&quot;characters animation&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;animation&quot;&gt;Animation&lt;/h3&gt;

&lt;p&gt;We create a set of 2 attack animations for each class using the standard Unity animation system with an animator controller, which allows us to record keyframes on the go and use a state machine animator for the logic.&lt;br /&gt;
Dead easy, but far from producing awesome motions.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/rogue-anim.gif&quot; title=&quot;characters modeling&quot; class=&quot;border col-lg-12&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;shiny-stats&quot;&gt;Shiny Stats&lt;/h3&gt;

&lt;p&gt;We will quickly setup the stats mechanisms thanks to Shiny Stats.&lt;/p&gt;

&lt;p&gt;I create a new empty GameObject, and attach the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ShinyStats&lt;/code&gt; script on it.
Let’s configure it with setting we have specified so far.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/unity-go-ss.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;br /&gt;
&lt;img src=&quot;/assets/blog/shinytinyrpg/ss-configured.png&quot; title=&quot;Shiny Stats&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Then, I attach the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ShinyStatsEntity&lt;/code&gt; script to the 6 characters and setup their respective classes.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/sse-setup.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;h4 id=&quot;class-configuration&quot;&gt;Class configuration&lt;/h4&gt;

&lt;p&gt;I convert the [&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+&lt;/code&gt; - &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+++&lt;/code&gt;] range we’ve designed before into real values.&lt;/p&gt;

&lt;ul class=&quot;nav nav-tabs&quot; role=&quot;tablist&quot;&gt;
  &lt;li class=&quot;nav-item&quot;&gt;
    &lt;a class=&quot;nav-link active&quot; href=&quot;#1a&quot; role=&quot;tab&quot; data-toggle=&quot;tab&quot;&gt;Reference&lt;/a&gt;
  &lt;/li&gt;
  &lt;li class=&quot;nav-item&quot;&gt;
    &lt;a class=&quot;nav-link&quot; href=&quot;#1b&quot; role=&quot;tab&quot; data-toggle=&quot;tab&quot;&gt;in Shiny Stats&lt;/a&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;tab-content&quot;&gt;
  &lt;div role=&quot;tabpanel&quot; class=&quot;tab-pane fade show active&quot; id=&quot;1a&quot;&gt;

&lt;div&gt;

      &lt;table class=&quot;table table-striped table-sm table-bordered&quot;&gt;
        &lt;thead&gt;
          &lt;tr&gt;
            &lt;th&gt;Class&lt;/th&gt;
            &lt;th&gt;Stamina&lt;/th&gt;
            &lt;th&gt;Strength&lt;/th&gt;
            &lt;th&gt;Intellect&lt;/th&gt;
            &lt;th&gt;Agility&lt;/th&gt;
            &lt;th&gt;&lt;em&gt;Sum&lt;/em&gt;&lt;/th&gt;
          &lt;/tr&gt;
        &lt;/thead&gt;
        &lt;tbody&gt;
          &lt;tr&gt;
            &lt;td&gt;&lt;strong&gt;Warrior&lt;/strong&gt;&lt;/td&gt;
            &lt;td&gt;10&lt;/td&gt;
            &lt;td&gt;15&lt;/td&gt;
            &lt;td&gt;5&lt;/td&gt;
            &lt;td&gt;7&lt;/td&gt;
            &lt;td&gt;37&lt;/td&gt;
          &lt;/tr&gt;
          &lt;tr&gt;
            &lt;td&gt;&lt;strong&gt;Paladin&lt;/strong&gt;&lt;/td&gt;
            &lt;td&gt;15&lt;/td&gt;
            &lt;td&gt;6&lt;/td&gt;
            &lt;td&gt;9&lt;/td&gt;
            &lt;td&gt;7&lt;/td&gt;
            &lt;td&gt;37&lt;/td&gt;
          &lt;/tr&gt;
          &lt;tr&gt;
            &lt;td&gt;&lt;strong&gt;Rogue&lt;/strong&gt;&lt;/td&gt;
            &lt;td&gt;5&lt;/td&gt;
            &lt;td&gt;8&lt;/td&gt;
            &lt;td&gt;9&lt;/td&gt;
            &lt;td&gt;15&lt;/td&gt;
            &lt;td&gt;37&lt;/td&gt;
          &lt;/tr&gt;
          &lt;tr&gt;
            &lt;td&gt;&lt;strong&gt;Wizard&lt;/strong&gt;&lt;/td&gt;
            &lt;td&gt;7&lt;/td&gt;
            &lt;td&gt;5&lt;/td&gt;
            &lt;td&gt;18&lt;/td&gt;
            &lt;td&gt;7&lt;/td&gt;
            &lt;td&gt;37&lt;/td&gt;
          &lt;/tr&gt;
          &lt;tr&gt;
            &lt;td&gt;&lt;strong&gt;Cleric&lt;/strong&gt;&lt;/td&gt;
            &lt;td&gt;12&lt;/td&gt;
            &lt;td&gt;5&lt;/td&gt;
            &lt;td&gt;13&lt;/td&gt;
            &lt;td&gt;7&lt;/td&gt;
            &lt;td&gt;37&lt;/td&gt;
          &lt;/tr&gt;
        &lt;/tbody&gt;
      &lt;/table&gt;

    &lt;/div&gt;

  &lt;/div&gt;
  &lt;div role=&quot;tabpanel&quot; class=&quot;tab-pane fade&quot; id=&quot;1b&quot;&gt;
  
&lt;img src=&quot;/assets/blog/shinytinyrpg/ss-class-setup.png&quot; class=&quot;border img-fluid&quot; /&gt;  
&lt;br /&gt;
&lt;small&gt;Classes configured in the Shiny Stats Unity asset&lt;/small&gt;

  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;h4 id=&quot;stats-configuration&quot;&gt;Stats configuration&lt;/h4&gt;

&lt;h5 id=&quot;experiences-stats&quot;&gt;Experiences stats&lt;/h5&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Experience&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Experience Drop&lt;/code&gt; stats will be the easiest stats to configure.
We will use arbitrary values for each level so it requires a single kill to level up from 1 to 2 and increase the difficulty gradually so there is ~10 enemies to grind at level 19 to reach 20.&lt;/p&gt;

&lt;ul class=&quot;nav nav-tabs&quot; role=&quot;tablist&quot;&gt;
  &lt;li class=&quot;nav-item&quot;&gt;
    &lt;a class=&quot;nav-link active&quot; href=&quot;#2a&quot; role=&quot;tab&quot; data-toggle=&quot;tab&quot;&gt;Reference&lt;/a&gt;
  &lt;/li&gt;
  &lt;li class=&quot;nav-item&quot;&gt;
    &lt;a class=&quot;nav-link&quot; href=&quot;#2b&quot; role=&quot;tab&quot; data-toggle=&quot;tab&quot;&gt;in Shiny Stats&lt;/a&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;tab-content&quot;&gt;
  &lt;div role=&quot;tabpanel&quot; class=&quot;tab-pane fade show active&quot; id=&quot;2a&quot;&gt;

&lt;div&gt;

      &lt;table class=&quot;table table-striped table-sm table-bordered&quot;&gt;
        &lt;thead&gt;
          &lt;tr&gt;
            &lt;th&gt;Level(s)&lt;/th&gt;
            &lt;th&gt;Experience Drop (on enemy’s death)&lt;/th&gt;
            &lt;th&gt;Experience (required to level up the player)&lt;/th&gt;
            &lt;th&gt;Expected kills to level up&lt;/th&gt;
          &lt;/tr&gt;
        &lt;/thead&gt;
        &lt;tbody&gt;
          &lt;tr&gt;
            &lt;td&gt;1-4&lt;/td&gt;
            &lt;td&gt;80 + Level x 10&lt;/td&gt;
            &lt;td&gt;100&lt;/td&gt;
            &lt;td&gt;1 - 2 enemies&lt;/td&gt;
          &lt;/tr&gt;
          &lt;tr&gt;
            &lt;td&gt;5-9&lt;/td&gt;
            &lt;td&gt;80 + Level x 10&lt;/td&gt;
            &lt;td&gt;250&lt;/td&gt;
            &lt;td&gt;2 - 4 enemies&lt;/td&gt;
          &lt;/tr&gt;
          &lt;tr&gt;
            &lt;td&gt;10-14&lt;/td&gt;
            &lt;td&gt;80 + Level x 10&lt;/td&gt;
            &lt;td&gt;500&lt;/td&gt;
            &lt;td&gt;3 - 6 enemies&lt;/td&gt;
          &lt;/tr&gt;
          &lt;tr&gt;
            &lt;td&gt;15-18&lt;/td&gt;
            &lt;td&gt;80 + Level x 10&lt;/td&gt;
            &lt;td&gt;1000&lt;/td&gt;
            &lt;td&gt;5 - 6 enemies&lt;/td&gt;
          &lt;/tr&gt;
          &lt;tr&gt;
            &lt;td&gt;19&lt;/td&gt;
            &lt;td&gt;80 + Level x 10&lt;/td&gt;
            &lt;td&gt;2000&lt;/td&gt;
            &lt;td&gt;10 - 11 enemies&lt;/td&gt;
          &lt;/tr&gt;
          &lt;tr&gt;
            &lt;td&gt;20&lt;/td&gt;
            &lt;td&gt;1500 (the boss)&lt;/td&gt;
            &lt;td&gt;-&lt;/td&gt;
            &lt;td&gt;-&lt;/td&gt;
          &lt;/tr&gt;
        &lt;/tbody&gt;
      &lt;/table&gt;

    &lt;/div&gt;
  &lt;/div&gt;

  &lt;div role=&quot;tabpanel&quot; class=&quot;tab-pane fade&quot; id=&quot;2b&quot;&gt;

&lt;img src=&quot;/assets/blog/shinytinyrpg/ss-exp-stat-setup.png&quot; class=&quot;border img-fluid&quot; /&gt;  
&lt;br /&gt;
&lt;small&gt;Experience stat configured in the Shiny Stats Unity asset&lt;/small&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;h5 id=&quot;walking-speed-stat&quot;&gt;Walking Speed stat&lt;/h5&gt;

&lt;p&gt;Walking speed will marginally increase with the level, but is extremely influenced by the initial &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Agility&lt;/code&gt; attribute of the character. The Rogue will walk way faster than other classes.&lt;br /&gt;
The funny mechanism that I certainly want to introduce is that the player won’t be able to outrun a rogue enemy (unless the player picked Rogue too).&lt;/p&gt;

&lt;p&gt;
  \(Walking Speed = 20 + Agility + Level / 10\)
&lt;/p&gt;

&lt;ul class=&quot;nav nav-tabs&quot; role=&quot;tablist&quot;&gt;
  &lt;li class=&quot;nav-item&quot;&gt;
    &lt;a class=&quot;nav-link active&quot; href=&quot;#3a&quot; role=&quot;tab&quot; data-toggle=&quot;tab&quot;&gt;Rogue class&lt;/a&gt;
  &lt;/li&gt;
  &lt;li class=&quot;nav-item&quot;&gt;
    &lt;a class=&quot;nav-link&quot; href=&quot;#3b&quot; role=&quot;tab&quot; data-toggle=&quot;tab&quot;&gt;Other classes&lt;/a&gt;
  &lt;/li&gt;
  &lt;li class=&quot;nav-item&quot;&gt;
    &lt;a class=&quot;nav-link&quot; href=&quot;#3c&quot; role=&quot;tab&quot; data-toggle=&quot;tab&quot;&gt;in Shiny Stats&lt;/a&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;tab-content&quot;&gt;
  &lt;div role=&quot;tabpanel&quot; class=&quot;tab-pane fade show active&quot; id=&quot;3a&quot;&gt;

&lt;img src=&quot;/assets/blog/shinytinyrpg/ss-stat-walkingspeed.png&quot; class=&quot;border img-fluid&quot; /&gt;  
&lt;br /&gt;
&lt;small&gt;Shiny Stats preview for the Rogue class&lt;/small&gt;
  &lt;/div&gt;
  &lt;div role=&quot;tabpanel&quot; class=&quot;tab-pane fade&quot; id=&quot;3b&quot;&gt;

&lt;img src=&quot;/assets/blog/shinytinyrpg/ss-stat-walkingspeed2.png&quot; class=&quot;border img-fluid&quot; /&gt;  
&lt;br /&gt;
&lt;small&gt;Shiny Stats preview for the Wizard class&lt;/small&gt;

  &lt;/div&gt;
  &lt;div role=&quot;tabpanel&quot; class=&quot;tab-pane fade&quot; id=&quot;3c&quot;&gt;

&lt;img src=&quot;/assets/blog/shinytinyrpg/ss-stat-walkingspeed-setup.png&quot; class=&quot;border img-fluid&quot; /&gt;  
&lt;br /&gt;
&lt;small&gt;Shiny Stats stat configuration&lt;/small&gt;

  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;h5 id=&quot;health-and-mana-stats&quot;&gt;Health and Mana stats&lt;/h5&gt;

&lt;p&gt;We will also use a linear function for Health and Mana to increase constantly over the levels. Unlike the walking speed, the Health and Mana will be mainly influenced by the level.&lt;/p&gt;

&lt;p&gt;
  \(Health Points = 20 + Stamina + Stamina * (Level - 1) / 4\)
&lt;/p&gt;

&lt;p&gt;
  \(Mana Points = Intellect + Intellect * (Level - 1) / 2\)
&lt;/p&gt;

&lt;h5 id=&quot;attack--spell-stats&quot;&gt;Attack &amp;amp; Spell stats&lt;/h5&gt;

&lt;p&gt;The remaining stats will use logarithms-like functions so the level affects them less and less.
The mechanics I want to introduce is that the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Health points&lt;/code&gt; will increase more than the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Attack Power&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Spell Power&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Attack Speed&lt;/code&gt; in the top levels, to extend the combats duration in the end game.&lt;/p&gt;

&lt;p&gt;
  \(Attack Power = Strength / 5 + Strength * log(Level, 10)\)
&lt;/p&gt;

&lt;p&gt;
  \(Spell Power = Intellect / 5 + Intellect * log(Level, 10)\)
&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/ss-rogue-attackpower.png&quot; class=&quot;border img-fluid&quot; /&gt;&lt;/p&gt;

&lt;h5 id=&quot;the-attack-speed-case&quot;&gt;The attack speed case&lt;/h5&gt;

&lt;p&gt;The Attack Speed is way more connected into the game and might mess around the animations and the experience overall.
To be fair, I didn’t plan very well for attack speed variation in the gameplay. So we will leave this as a constant in Shiny Stats.&lt;/p&gt;

&lt;p&gt;
  \(Attack Speed = 0.2\)
&lt;/p&gt;

&lt;p&gt;This will be the minimum, constant, period of time in seconds between two consecutive attacks regardless the class and level.&lt;/p&gt;

&lt;h3 id=&quot;uis&quot;&gt;UIs&lt;/h3&gt;

&lt;p&gt;With a reduced amount of UI panels, we can keep the game really tiny and still showing off a lot of stats… from Shiny Stats.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/ui-state.png&quot; class=&quot;border col-lg-4&quot; /&gt;&lt;br /&gt;
&lt;small&gt;UI panels state machine&lt;/small&gt;&lt;/p&gt;

&lt;h3 id=&quot;c-scripts&quot;&gt;C# scripts&lt;/h3&gt;

&lt;h4 id=&quot;player-movement&quot;&gt;Player Movement&lt;/h4&gt;

&lt;p&gt;We will use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CharacterController&lt;/code&gt; component from Unity to handle the player’s movements.
The initial code, without the extra game management logic and Shiny Stats, looks like the code below.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;UnityEngine&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;PlayerMovement&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MonoBehaviour&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CharacterController&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CharacterController&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;Awake&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;CharacterController&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GetComponent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharacterController&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;FixedUpdate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;moveHorizontal&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetAxisRaw&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Horizontal&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;moveVertical&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetAxisRaw&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Vertical&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;movement&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;moveHorizontal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0F&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;moveVertical&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;normalized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

        &lt;span class=&quot;n&quot;&gt;CharacterController&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Move&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;movement&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fixedDeltaTime&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0.2F&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;movement&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Vector3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;zero&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;transform&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rotation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Quaternion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Lerp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;transform&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rotation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Quaternion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;LookRotation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;movement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0.5F&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;health-system&quot;&gt;Health System&lt;/h4&gt;

&lt;p&gt;The health system will cover the characters life and death.&lt;br /&gt;
We reload the scene to restart the game when the player dies.
When this script is attached to an enemy, it will give experience points to the player on death.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ShinyStatsn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;// Enable ShinyStats.&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;UnityEngine&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;UnityEngine.SceneManagement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireComponent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;typeof&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))]&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;HealthSystem&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MonoBehaviour&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;double&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MaxValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;double&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CurrentValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;Awake&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GetComponent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// Subscribe to the OnLevelChanged event.&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OnLevelChanged&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity_OnLevelChanged&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;/// &amp;lt;summary&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;/// Callback from ShinyStatsEntity when the Level change.&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;/// &amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;ShinyStatsEntity_OnLevelChanged&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;object&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sender&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;EventArgsInt&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;MaxValue&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EvaluateStat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Health Points&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;CurrentValue&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MaxValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Reset to 100% HP on level up.&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;/// &amp;lt;summary&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;/// Handle the damage dealt by another entity.&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;/// Negative values are perceived as heal.&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;/// &amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;/// &amp;lt;param name=&quot;value&quot;&amp;gt;&amp;lt;/param&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;ReceiveDamage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;double&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;CurrentValue&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CurrentValue&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CurrentValue&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tag&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;gameObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tag&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

            &lt;span class=&quot;nf&quot;&gt;Destroy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gameObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tag&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Player&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;SceneManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;LoadScene&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SceneManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetActiveScene&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Reset the game on player death.&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;c1&quot;&gt;// Drop experience to the player on enemy death.&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;player&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FindObjectOfType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ShinyTinyRPGManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;().&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetPlayerGo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;expDrop&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EvaluateStat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Experience Drop&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;player&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GetComponent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;().&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddExp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;expDrop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;We can also add a passive health regen that we always have in RPG games.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;FixedUpdate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Regen of the Health Points over the time.&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CurrentValue&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MaxValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;CurrentValue&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deltaTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;charactersheet-ui-script&quot;&gt;CharacterSheet UI script&lt;/h4&gt;

&lt;p&gt;A last script good to share is the character sheet panel code.&lt;/p&gt;

&lt;p&gt;We adopted a stateless design that solves the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ShinyStatsEntity&lt;/code&gt; reference on its own when possible.
Indeed, the player can change its character at any moment, breaking references due to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GameOject.Destroy()&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GameObject.Instanciate()&lt;/code&gt; actions I am using.
There are many ways to get over this, but this design is modular and maintainable.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ShinyStatsn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;UnityEngine&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;using&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;UnityEngine.UI&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;CharacterSheetUI&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MonoBehaviour&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Text&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Subtitle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Set in the Inspector.&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AttributesValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Set in the Inspector.&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;StatsValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Set in the Inspector.&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Text&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SpecialSkill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Set in the Inspector.&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;FixedUpdate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;go&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GameObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;FindWithTag&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Player&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;go&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;go&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GetComponent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

        &lt;span class=&quot;n&quot;&gt;subtitle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ClassSelected&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;label&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot; Lv. &quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CurrentLevel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

        &lt;span class=&quot;n&quot;&gt;AttributesValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Stamina&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;AttributesValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Strength&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;AttributesValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Intellect&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;AttributesValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Agility&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

        &lt;span class=&quot;n&quot;&gt;StatsValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EvaluateStat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Health Points&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;StatsValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EvaluateStat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Mana Points&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;StatsValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EvaluateStat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Attack Power&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;StatsValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EvaluateStat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Spell Power&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;StatsValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EvaluateStat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Attack Speed&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;StatsValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ShinyStatsEntity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;EvaluateStat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Walking Speed&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;0.#&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;An performance improvement is possible: register to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OnLevelChanged&lt;/code&gt; event to catch updates instead of looping in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FixedUpdate&lt;/code&gt; loop.&lt;/p&gt;

&lt;h2 id=&quot;final-result&quot;&gt;Final result&lt;/h2&gt;

&lt;p&gt;&lt;a class=&quot;btn btn-primary&quot; href=&quot;https://shinystats.jorislacance.fr/shiny-tiny-rpg/&quot; target=&quot;_blank&quot;&gt;Try the demo (from the Shiny Stats website)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/ui-charsheet-panel.png&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Character Sheet panel&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/ui-classselect-panel.png&quot; class=&quot;border col-lg-8 &quot; /&gt;&lt;br /&gt;
&lt;small&gt;Class Selection panel&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/ui-game-panel.png&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;br /&gt;
&lt;small&gt;Game panel&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/demo4.gif&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/demo1.gif&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/demo2.gif&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/demo3.gif&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/blog/shinytinyrpg/demo5.gif&quot; class=&quot;border col-lg-8&quot; /&gt;&lt;/p&gt;</content><author><name></name></author><category term="blog" /><category term="game dev" /><category term="Unity" /><category term="C#" /><category term="Shiny Stats" /><summary type="html">Intro</summary></entry></feed>