<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rosen Alexandrov</title>
    <description>The latest articles on DEV Community by Rosen Alexandrov (@rosenalexandrov).</description>
    <link>https://dev.to/rosenalexandrov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4043092%2Fce8008eb-9fc4-4d10-b5b4-34dc811f1129.png</url>
      <title>DEV Community: Rosen Alexandrov</title>
      <link>https://dev.to/rosenalexandrov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rosenalexandrov"/>
    <language>en</language>
    <item>
      <title>Monte Carlo for casino bonuses: the expected value of free spins hides a 90% chance of nothing</title>
      <dc:creator>Rosen Alexandrov</dc:creator>
      <pubDate>Tue, 03 Nov 2026 09:16:12 +0000</pubDate>
      <link>https://dev.to/rosenalexandrov/monte-carlo-for-casino-bonuses-the-expected-value-of-free-spins-hides-a-90-chance-of-nothing-2mi8</link>
      <guid>https://dev.to/rosenalexandrov/monte-carlo-for-casino-bonuses-the-expected-value-of-free-spins-hides-a-90-chance-of-nothing-2mi8</guid>
      <description>&lt;p&gt;In an &lt;a href="https://dev.to/casinokit/the-math-behind-a-casino-bonus-wagering-calculator-and-why-most-100-bonuses-are-worth-less-than-4jl8"&gt;earlier post&lt;/a&gt; I used a one-line formula for the value of a casino bonus: winnings minus the expected cost of the wagering. It is fast and it is what most calculators use, ours included. But a single expected value hides the thing players actually experience: most of the time the result is nothing, and once in a while it is a lot.&lt;/p&gt;

&lt;p&gt;So I wrote a small Monte Carlo simulation in plain JavaScript and ran 100,000 players through the same offers. The averages barely surprised me. The distributions did.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: this is a probability exercise, not advice to gamble. Gambling is 18+, and every simulated slot here has a house edge, exactly like real ones.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A toy slot with a 96% RTP
&lt;/h2&gt;

&lt;p&gt;Real slots do not publish their pay tables, so the model is a simplified slot: a spin pays a multiple of the bet with a fixed probability, and the probabilities are scaled so the return to player is exactly 96%.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.004&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0006&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.00004&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.96&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;   &lt;span class="c1"&gt;// RTP = 96%&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;spin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;mult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;PAYS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;mult&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;bet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About half of all spins pay something, most wins are small, and a 500x hit comes roughly once in 12,500 spins. &lt;code&gt;rand()&lt;/code&gt; is a seeded generator (mulberry32 with seed 42), so every number in this post can be reproduced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free spins, then wagering
&lt;/h2&gt;

&lt;p&gt;The offer: 50 free spins at €0.20. The winnings are credited as bonus money, and before you can withdraw them you must bet them a number of times at €0.20 per spin. If the balance drops below one bet, you are out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;freeSpinsTrial&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;spins&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wagering&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;won&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;spins&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;won&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;spin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bet&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wagering&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;won&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;won&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;won&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wagered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;won&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;wagering&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wagered&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// bust before the wagering is done&lt;/span&gt;
    &lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;bet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;wagered&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;bet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;spin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bet&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it 100,000 times and look at more than the mean:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offer&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Players who get €0&lt;/th&gt;
&lt;th&gt;Top 1% get at least&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50 spins, no wagering&lt;/td&gt;
&lt;td&gt;€9.53&lt;/td&gt;
&lt;td&gt;€7.60&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;€31.90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 spins, no wagering, €20 cap&lt;/td&gt;
&lt;td&gt;€8.76&lt;/td&gt;
&lt;td&gt;€7.70&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;€20.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 spins, 35x wagering&lt;/td&gt;
&lt;td&gt;€5.58&lt;/td&gt;
&lt;td&gt;€0&lt;/td&gt;
&lt;td&gt;90.2%&lt;/td&gt;
&lt;td&gt;€123.70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 spins, 35x wagering, €50 cap&lt;/td&gt;
&lt;td&gt;€3.33&lt;/td&gt;
&lt;td&gt;€0&lt;/td&gt;
&lt;td&gt;90.4%&lt;/td&gt;
&lt;td&gt;€50.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The no-wagering mean matches the formula.&lt;/strong&gt; 50 spins x €0.20 x 96% = €9.60, and the simulation lands on €9.53. Good sign that the model behaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At 35x the formula and the simulation disagree.&lt;/strong&gt; The linear estimate says the expected cost of wagering is 35 x winnings x 4%, which is more than the winnings themselves, so the value is zero. The simulation says the average is €5.58. The difference is the bust rule: a player can never lose more than the bonus balance, so the losses are cut off, while the rare lucky run keeps its full upside. The average is pulled up by the top few percent, and the median player walks away with nothing.&lt;/p&gt;

&lt;p&gt;That is the honest summary of a 35x free-spin offer: positive on average, nothing for 9 players in 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a cap does to the tail
&lt;/h2&gt;

&lt;p&gt;Look at the capped row. The €50 maximum win barely changes how many players get nothing (90.4%), but it cuts the mean from €5.58 to €3.33. A cap does not hurt the typical player. It removes the lucky outcomes that made the offer worth anything on average. With 35x wagering, the cap is often the most important number in the terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cashback: same money, different shape
&lt;/h2&gt;

&lt;p&gt;The same simulation works for cashback. Take €10 of cashback and change only the wagering attached to it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Wagering on €10 cashback&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Players who get €0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;None (paid as cash)&lt;/td&gt;
&lt;td&gt;€10.00&lt;/td&gt;
&lt;td&gt;€10.00&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1x&lt;/td&gt;
&lt;td&gt;€9.61&lt;/td&gt;
&lt;td&gt;€7.70&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10x&lt;/td&gt;
&lt;td&gt;€7.60&lt;/td&gt;
&lt;td&gt;€0&lt;/td&gt;
&lt;td&gt;72.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;35x&lt;/td&gt;
&lt;td&gt;€6.11&lt;/td&gt;
&lt;td&gt;€0&lt;/td&gt;
&lt;td&gt;89.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cashback with no wagering is a fixed €10. At 1x it is still close to that and nobody ends with nothing. From 10x on it turns into a lottery ticket: the mean stays respectable, but most players lose the whole amount while trying to clear it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Report the distribution, not just the mean.&lt;/strong&gt; For anything with high wagering, the median and the share of players who get €0 say more than the expected value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The linear formula is a floor, not the truth.&lt;/strong&gt; It ignores that a bust stops your losses, so at high wagering it understates the average and hides how unequal the outcomes are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caps attack the tail.&lt;/strong&gt; A max-win cap changes little for most players and a lot for the average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low wagering changes the shape.&lt;/strong&gt; Going from 35x to 1x does not just raise the average, it removes the 90% chance of nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want the quick closed-form estimate for a real offer (spins, spin value, wagering, cap and RTP), the &lt;a href="https://casinokit.app/tools/free-spins-value/" rel="noopener noreferrer"&gt;free spins value calculator on CasinoKit&lt;/a&gt; gives it in one click. Treat it as the starting point, and remember what the single number leaves out.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>math</category>
      <category>datascience</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a manual camera app for iPhone: what I learned fighting for exact exposure values</title>
      <dc:creator>Rosen Alexandrov</dc:creator>
      <pubDate>Sun, 01 Nov 2026 09:44:08 +0000</pubDate>
      <link>https://dev.to/rosenalexandrov/building-a-manual-camera-app-for-iphone-what-i-learned-fighting-for-exact-exposure-values-2oo2</link>
      <guid>https://dev.to/rosenalexandrov/building-a-manual-camera-app-for-iphone-what-i-learned-fighting-for-exact-exposure-values-2oo2</guid>
      <description>&lt;p&gt;I'm a photographer first and a developer second. For years, the thing that bothered me most about shooting on an iPhone was that the built-in Camera app has no manual ISO, and that every photo goes through a processing pipeline you can't switch off. So I built my own camera app, &lt;strong&gt;Real ProCam&lt;/strong&gt;. It's free on the App Store, and version 1.2 just went live.&lt;/p&gt;

&lt;p&gt;This isn't a tutorial. It's a list of things that surprised me while trying to keep one simple promise: &lt;strong&gt;the ISO, shutter speed and aperture you set are the values in the file.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Setting a custom exposure is easy. Keeping it is not.
&lt;/h2&gt;

&lt;p&gt;AVFoundation lets you set a custom exposure with &lt;code&gt;setExposureModeCustom(duration:iso:)&lt;/code&gt;. Getting the preview to look right takes an afternoon. Getting the &lt;em&gt;photo&lt;/em&gt; to carry the same values took me weeks.&lt;/p&gt;

&lt;p&gt;The reason is the capture pipeline. Depending on the photo settings, the system may capture and fuse several frames, and the final file can report values that differ from what you set. For a photographer, that breaks the whole point of manual mode. In the end the answer was simpler than I expected: with manual exposure, every rear lens takes a single frame with minimal processing and no frame merging, and the app only fires once the camera has confirmed the exact ISO and shutter speed. That keeps the values in the file exactly as set.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Every camera has its own limits, so read them instead of hardcoding them
&lt;/h2&gt;

&lt;p&gt;The supported ISO range and exposure durations differ per device and per lens, and they change between generations. A hardcoded table for "iPhone 13 and later" was a dead end. Instead, the app reads &lt;code&gt;minISO&lt;/code&gt;, &lt;code&gt;maxISO&lt;/code&gt;, &lt;code&gt;minExposureDuration&lt;/code&gt; and &lt;code&gt;maxExposureDuration&lt;/code&gt; from the active format of each camera and builds the 1/3-stop dials from those values. As a bonus, the app automatically offers the full range of whatever hardware it runs on, with no artificial caps.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The fastest shutter speeds are quantized by the sensor
&lt;/h2&gt;

&lt;p&gt;This one surprised me. Set 1/32,000 s on the main camera and the file says something else. At very short exposures, the sensor exposes in fixed steps tied to its readout, so the real duration snaps to the nearest step it can actually do. On the main camera I cap the dial at 1/25,000 s on 1× and 1/16,000 s on 2×, because faster settings simply didn't produce faster files. The app also says openly that at those speeds the photo can differ from the setting by up to about 5%, instead of pretending otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. SwiftUI view identity can freeze your camera preview
&lt;/h2&gt;

&lt;p&gt;In version 1.2, I made the 16:9 viewfinder fill the height of the screen. The first build had a nasty bug: switching the aspect ratio froze the preview on black for several seconds. The cause was a branching &lt;code&gt;if/else&lt;/code&gt; in the layout. Each branch created its own &lt;code&gt;UIViewRepresentable&lt;/code&gt;, so SwiftUI tore down the old view and built a new one, attaching a fresh &lt;code&gt;AVCaptureVideoPreviewLayer&lt;/code&gt; to a session that was already running.&lt;/p&gt;

&lt;p&gt;The fix was a single preview view, created once and kept alive, with only its frame and &lt;code&gt;videoGravity&lt;/code&gt; changing between aspect ratios. Switching is now instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ask for permissions at the first launch
&lt;/h2&gt;

&lt;p&gt;Another 1.2 lesson: ask for camera and Photos access when the app first opens, not when the user presses the shutter. Otherwise the very first photo someone takes can be lost while the permission dialog is on screen. If access is refused, the app explains clearly that it can't work without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the app does today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Manual ISO, shutter speed and aperture (f/1.48 to f/4 on iPhones with a variable-aperture lens), or Auto for any of them&lt;/li&gt;
&lt;li&gt;Your values in the file on every rear lens: a single frame with minimal processing, with no frame merging and no filters&lt;/li&gt;
&lt;li&gt;RAW (DNG), ProRAW, uncompressed TIFF, HEIF and JPEG&lt;/li&gt;
&lt;li&gt;Every rear lens, plus 8× (200 mm) on iPhone 17 Pro and iPhone 18 Pro&lt;/li&gt;
&lt;li&gt;Manual focus, white balance presets, exposure compensation, a live histogram and an exposure meter&lt;/li&gt;
&lt;li&gt;iPhone 13 and later&lt;/li&gt;
&lt;li&gt;Free, with no ads, no account and no tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you're into photography or camera APIs, I'd really like your feedback, especially if you've fought AVFoundation's exposure behaviour yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real ProCam&lt;/strong&gt; on the App Store: &lt;a href="https://apps.apple.com/us/app/real-procam/id6819234474" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/real-procam/id6819234474&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>photography</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The OKLCH workflow that finally made my palettes survive contact with real screens</title>
      <dc:creator>Rosen Alexandrov</dc:creator>
      <pubDate>Thu, 23 Jul 2026 05:51:20 +0000</pubDate>
      <link>https://dev.to/rosenalexandrov/the-oklch-workflow-that-finally-made-my-palettes-survive-contact-with-real-screens-59ik</link>
      <guid>https://dev.to/rosenalexandrov/the-oklch-workflow-that-finally-made-my-palettes-survive-contact-with-real-screens-59ik</guid>
      <description>&lt;p&gt;I spent years picking colors the same way most of us do. Eyedrop a hex from a mockup, massage it in HSL until it looks right, ship it, and then watch it fall apart. The hover state turns muddy. The chart series that looked distinct on my monitor becomes a blur on a cheap office display. An accessibility audit flags the secondary button text I never thought to check.&lt;/p&gt;

&lt;p&gt;So I did what developers do. I got annoyed enough to build a tool. That tool became Shademix, and somewhere along the way it stopped being my personal utility and turned into something thousands of designers, decorators and developers now use every day. Partly because it costs nothing, sure. But mostly, judging by the mail I get, because it fixes the exact chain of small failures I just described. This post is the workflow baked into it. You can follow it with any tools you like, the ideas are not mine to own.&lt;/p&gt;

&lt;h2&gt;
  
  
  HSL lies about lightness
&lt;/h2&gt;

&lt;p&gt;Here is the root problem. In HSL, &lt;code&gt;hsl(60 100% 50%)&lt;/code&gt; and &lt;code&gt;hsl(240 100% 50%)&lt;/code&gt; claim identical lightness. Look at them side by side: the yellow glows, the blue is nearly dark. Every palette built by spinning the hue wheel in HSL inherits that dishonesty, which is why one tag color always pops and another always sinks.&lt;/p&gt;

&lt;p&gt;OKLCH fixes it. Its lightness channel is perceptual, meaning two colors with equal L genuinely look equally bright. Once you have that, three chores become mechanical. Harmonies: rotate hue, keep L and C, and complementary, triadic, tetradic, split, square and monochromatic schemes all come out visually balanced. Tints and shades: move L alone and the hue stops drifting toward mud. Contrast: keep text near L 0.95 and surfaces under 0.35 and you rarely fail WCAG by accident.&lt;/p&gt;

&lt;p&gt;Support is a non-issue now, &lt;code&gt;oklch()&lt;/code&gt; works in every current browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.btn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#2f6fde&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c"&gt;/* old-engine fallback */&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.55&lt;/span&gt; &lt;span class="m"&gt;0.17&lt;/span&gt; &lt;span class="m"&gt;262&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.btn&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.48&lt;/span&gt; &lt;span class="m"&gt;0.17&lt;/span&gt; &lt;span class="m"&gt;262&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c"&gt;/* only L moves, hue stays clean */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Sample the color people actually see
&lt;/h2&gt;

&lt;p&gt;If the brand color lives in a photo or a screenshot, a single-pixel eyedropper will betray you. JPEG compression speckles flat areas with near-misses, and the pixel you happen to hit can sit a visible distance from the color everyone perceives. Sample an area, or take the dominant cluster.&lt;/p&gt;

&lt;p&gt;In Shademix this is the Pick tab: drop an image, paste a screenshot with Ctrl or Cmd+V, point the live camera at a wall, or grab from the screen. You get the color in HEX, RGB, HSL, HSV, CMYK, LAB, LCH and OKLCH at once, so whatever the next tool in your chain expects, the value is one tap away. There is also a loupe for the times you really do need one exact pixel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Name it in the system your client speaks
&lt;/h2&gt;

&lt;p&gt;Developers think in hex. Print shops think in CMYK and Pantone. Architects spec RAL, Scandinavian clients bring NCS, painters bring fan decks. The lookup side of the tool matches any color across ten reference systems and, crucially, shows the delta-E distance on every match. That number is the honesty the usual "closest match" hides. Under 2, nobody can tell the difference. Above 5, everyone can. Knowing which side you are on before ordering paint or a print run has saved me real money more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the scheme, then interrogate it
&lt;/h2&gt;

&lt;p&gt;Picking the palette is the fun part. The part that keeps you out of trouble is the interrogation, and it is exactly the part teams skip when it lives in a separate tool they have to remember to open.&lt;/p&gt;

&lt;p&gt;So the workflow puts it inline. Run the whole palette through a pairwise WCAG matrix, every foreground against every background, AA and AAA, normal and large text. Five colors is twenty ordered pairs, which is nothing for a machine and torture by hand. Then run the color blindness simulation: protanopia, deuteranopia, tritanopia, achromatopsia. Roughly eight percent of men see color differently, and if your error and success states only differ by hue, some users simply cannot tell them apart. Then the lighting preview, which renders the palette under six illuminants from warm tungsten to north-sky daylight, because a color that changes character between the showroom and the living room is a complaint waiting to be filed. And if anything is headed for paper, the CMYK gamut warning tells you upfront which saturated screen colors will clip on an offset press, and suggests the nearest printable neighbour.&lt;/p&gt;

&lt;p&gt;None of this is exotic color science. It is just moved to where you already are, so it actually happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship values, not screenshots
&lt;/h2&gt;

&lt;p&gt;A palette that lives in a screenshot dies in a screenshot. Export it in the format the code expects: CSS variables, a Tailwind config, JSON, SVG, or straight to design tools as Figma tokens, Sketch or Procreate swatches, Adobe ASE, Photoshop ACO, GIMP GPL. Eleven formats out, nine formats in, so the palette a client mails you as an .ase file lands in the same library as the one you sampled from a site photo. Everything can be filed into per-client projects, compared four at a time as aligned strips, shared as a plain link with the colors encoded in the URL, or exported as a print-ready PDF with crop marks when the deliverable is physical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that surprised people
&lt;/h2&gt;

&lt;p&gt;Shademix runs entirely on your device. Browser, iPhone, iPad, Mac, Vision Pro, and Android on the way. Nothing you sample or import is uploaded anywhere, there is no account, no tracking, and no paywall waiting at the end. When it started spreading I assumed the reason was simply "free". Reading the feedback, the actual reason seems to be that it holds the whole chain, sample, match, harmonize, verify, export, in one place, so the verification steps stop being optional.&lt;/p&gt;

&lt;p&gt;Take the workflow even if you never touch the tool: pick in OKLCH, sample areas not pixels, demand the delta-E, run the contrast matrix and the CVD simulation every time, and export real values. Your future self, staring at a hover state that finally is not muddy, will be grateful.&lt;/p&gt;

&lt;p&gt;If you want to try it, it is at &lt;a href="https://shademix.com/" rel="noopener noreferrer"&gt;shademix.com&lt;/a&gt;, and the deeper guides with screenshots live on &lt;a href="https://shademix.app/" rel="noopener noreferrer"&gt;shademix.app&lt;/a&gt;. Free, no account, in your browser.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>a11y</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
