Neon Royale

Reel Strips & Symbol Weights: How to Calculate Optimal Reel Compositions

Neon AdminNeon Admin·Mar 12, 2026
Reel Strips & Symbol Weights: How to Calculate Optimal Reel Compositions

Introduction

You have your target RTP. You have your pay table. You know what volatility profile you're aiming for. Now comes the question that turns all that theory into a real game: how many of each symbol do you put on each reel?

This is the reel strip design problem, and it is simultaneously the most mathematically precise and the most iteratively creative step in building a slot. Get it right and your game hits 96.00% RTP with the volatility signature you intended. Get it wrong and your game either bleeds money for the operator, fails certification, or — worst of all — plays nothing like you designed.

Reel strips are where mathematical intent meets engineering reality. Every symbol count is a lever. Change Diamond on Reel 3 from 2 to 3 and your 5-of-a-kind probability jumps by 50% — cascading through RTP, hit frequency, and variance in ways that are easy to underestimate. The interdependencies are non-linear, the parameter space is enormous, and the constraints are tight.

This article gives you a complete framework for reel strip design: the theory of symbol weights, the mathematics of how counts translate into probabilities and RTP, systematic iterative techniques, constraint-based optimisation, and a full C# implementation of a reel strip solver that you can use as the backbone of your own PAR Sheet tooling.


Part I. Foundational Concepts

1.1 What a Reel Strip Actually Is

A reel strip is the master sequence of symbols that forms the virtual drum of one reel. Think of it as a physical tape of symbols wound around a cylinder. When the player hits spin, the game generates a random stop position — an integer in [0, stripLength) — and the three symbols visible on screen are the symbol at that position plus the ones immediately above and below it in the strip.

Strip position index:  0   1   2   3   4   5   6   7   8   9  ...
Symbol on strip:       Q   K   A   ♦   Q   W   J   ♣   Q   K  ...

Random stop = 5:
  Visible row 0 (top):    strip[4] = Q
  Visible row 1 (middle): strip[5] = W  ← stop position
  Visible row 2 (bottom): strip[6] = J

The strip wraps around: if the stop is position 0, the top symbol is:

strip[stripLength - 1]

This means the physical position of symbols on the strip matters, not just their count. Two strips with identical symbol counts but different arrangements will have the same long-run probability distribution but different near-miss frequencies and different visual pacing — which affects perceived volatility even when mathematical volatility is identical.

1.2 The Weight of a Symbol

The weight of a symbol on a reel is simply its count — how many times it appears across the reel's strip length:

weight(symbol S, reel R) = count(S on R)

The probability of that symbol appearing at any given visible row position is:

P(S visible at row r on reel R) = count(S on R) / stripLength(R)

This is the atomic unit from which all combination probabilities are built. Five weights — one per reel — fully determine the probability of every payline combination for that symbol.

1.3 Why Strip Length Matters

Strip length is a degree of freedom that fundamentally shapes what is achievable in reel design.

Short strips (20–25 positions): Simple, easy to reason about, but limited resolution. You can't express a probability like 3.2% precisely — you're stuck with discrete fractions like 3/25 = 12% or 1/25 = 4%. Very common in classic 3-reel mechanical slots.

Medium strips (32–64 positions): The sweet spot for video slots. Fine enough resolution for precise tuning while remaining tractable for manual design. A 32-position strip allows probabilities in multiples of 3.125%.

Long strips (96–512 positions): Used in sophisticated modern titles. Near-continuous probability resolution. Reel 3 might have 512 positions to accommodate very fine-tuned Wild probabilities and near-miss engineering. Essentially impossible to design by hand — requires algorithmic tools.

Variable-length strips: Different reels can have different lengths. Common design: outer reels shorter (32–48), middle reel longer (48–96). This gives the middle reel finer weight resolution without inflating the total cycle size unnecessarily.

The cycle size constraint:

Cycle = R1 × R2 × R3 × R4 × R5

If all five reels are 512 positions: Cycle = 512⁵ = 34,359,738,368 (~34 billion). Full enumeration of this takes ~15 minutes in optimised C#. This is the upper bound before you need Monte Carlo simulation instead of exact enumeration. For most projects, keep the cycle below 10⁹.

1.4 The Visible Window and Effective Probability

A critical subtlety: when a reel stops, three symbols become visible (for a 3-row game). This means a symbol that appears once on the strip is visible in 3 out of stripLength stop positions — once as the top symbol, once as the middle, once as the bottom.

This matters enormously for Scatter symbols, which pay anywhere on the visible grid regardless of row. For payline-based wins, only the specific row of the payline matters:

For PAYLINE wins (row-specific):
P(symbol S at row r on reel R) = count(S on R) / stripLength(R)

For SCATTER wins (visible anywhere):
P(symbol S visible anywhere on reel R) = (count(S on R) × 3) / stripLength(R)
                                        [for a 3-row game]

Getting this distinction wrong is one of the most common errors in PAR Sheet calculations, as we noted in the previous article. For a symbol with count = 1 on a 32-position strip:

P(on specific payline row) = 1/32  = 3.125%
P(visible anywhere in window) = 3/32 = 9.375%

The scatter probability is three times higher. A designer who accidentally uses the payline formula for scatter calculations will underestimate the bonus trigger frequency by a factor of three.


Part II. The Mathematics of Weight-to-RTP Translation

2.1 Single Symbol, Single Line: The Base Calculation

Let's build the complete mathematical relationship from symbol weights to RTP contribution, step by step.

For a 5-reel, single-payline game (simplification we'll relax later), the probability of getting exactly symbol S on all five reels on that payline is:

P(S,S,S,S,S) = (w1/L1) × (w2/L2) × (w3/L3) × (w4/L4) × (w5/L5)

Where wi = weight of S on reel i, Li = strip length of reel i.

The RTP contribution of this combination:

RTP(5×S) = P(S,S,S,S,S) × payout(5×S)

2.2 Including Wild Substitutions

Wild adds additional ways to form every combination. For "5×S with Wild substitution allowed":

Ways_5×S = Π [wi(S) + wi(Wild)] over all reels i
           - Π [wi(Wild)] over all reels i     ← subtract pure-Wild combos

More generally, for a k-of-a-kind win (k symbols on reels 1 through k, not on reels k+1 through 5):

Ways_k×S = [Π_{i=1}^{k} (wi(S) + wi(Wild))]
          × [Π_{i=k+1}^{5} (Li - wi(S) - wi(Wild))]
          - [Π_{i=1}^{k} wi(Wild)]
            × [Π_{i=k+1}^{5} (Li - wi(S) - wi(Wild))]

The second line subtracts the "all Wilds" sub-case, which belongs to its own combo category.

2.3 The Marginal Weight Equation

A key insight for reel strip design: how does RTP change when you add one more symbol S to reel R?

Let's compute the partial derivative of RTP with respect to wR(S) — the weight of symbol S on reel R.

For a 5-of-a-kind combination (ignoring Wild for clarity):

RTP(5×S) = [w1/L1 × w2/L2 × w3/L3 × w4/L4 × w5/L5] × payout(5×S)

∂RTP(5×S)/∂wR = [1/LR × Π_{i≠R} wi/Li] × payout(5×S)

This is the RTP gain per unit increase in weight on reel R.

What this tells you: adding one more symbol S to reel R multiplies the 5-of-a-kind probability by (wR + 1) / wR.

If wR is currently 1 (one Diamond on reel 3) and you add one more, you double the 5-Diamond probability. If wR is already 8, adding one more only increases it by 12.5%.

The marginal return diminishes with existing weight. This is why early additions to premium symbols have huge RTP impact and later additions are increasingly minor.

2.4 The Full RTP Equation for a Complete Pay Table

Expanding across all symbol combinations, all k values, and all lines:

RTP_base = Σ_symbols Σ_{k=3}^{5} [Ways(k×S) / Cycle × payout(k×S)]
         + Ways(5×Wild) / Cycle × payout(5×Wild)
         + [contributions from Wild-only 3-of-a-kind and 4-of-a-kind if applicable]

Where:

Cycle = Π_{i=1}^{5} Li

And Ways(k×S) is calculated using the combinatorial formula from Section 2.2 above, accounting for Wild substitution.

This equation is what you're solving backwards when you design reel strips: you know the target RTP_base, you know the pay table, and you're looking for weight values {wij} that satisfy the equation.

2.5 The Degrees of Freedom Problem

For a 5-reel slot with 11 symbols and strips of variable length, the weight matrix has dimensions 11 × 5 = 55 values. But each column must sum to the strip length, so there are effectively 11 - 1 = 10 free weights per reel × 5 reels = 50 degrees of freedom.

You have one RTP constraint. If you want to additionally target specific values of:

Hit Frequency (1 constraint)

Variance (1 constraint)

Bonus trigger frequency (1 constraint)

5-of-a-kind top symbol probability (1 constraint)

Wild frequency (1 constraint)

That's 6 constraints against 50 degrees of freedom. The system is massively underdetermined — there are infinitely many reel strips that satisfy your mathematical requirements. The "excess" degrees of freedom are what you use for aesthetic choices: near-miss engineering, visual rhythm, symbol placement patterns.

This is both good news (there's always a solution) and the source of the difficulty (how do you find a good one from an infinite space?).


Part III. The Weight Calculation Process

3.1 Starting From Target Probabilities

The practical workflow begins not with weights but with target probabilities for each combination. You define these from your pay table and RTP budget.

Step 1: Allocate your RTP budget

Decide how much RTP each combination contributes. This is a creative and mathematical decision simultaneously.

Example allocation for our Crystal Forge slot (96% total, 58% base):

Symbol      3×     4×     5×    Subtotal
─────────────────────────────────────────
WILD        —      —      0.43%   0.43%
DIAMOND     12.87% 5.91%  2.15%   20.93%
RUBY        14.65% 6.10%  1.53%   22.28%
EMERALD     11.54% 5.41%  1.16%   18.11%  ← too high, trim
GOLD         8.45% 4.83%  1.09%   14.37%
SILVER       9.27% 4.35%  1.16%   14.78%
ACE          3.66% 1.47%  0.46%    5.59%
KING         2.75% 1.10%  0.34%    4.19%
QUEEN        3.35% 2.75%  1.16%    7.26%  ← Queen 5× too high vs King
JACK         2.64% 1.24%  0.46%    4.34%
─────────────────────────────────────────
TOTAL                             65.03%  ← target ~58%, need revision

This tells you immediately that the current pay table + weight estimates produce more RTP than the base game budget allows. Time to rebalance — either cut payouts, reduce high-symbol weights, or both.

Step 2: Back-calculate target probabilities

For each combination, the target probability is:

P_target(k×S) = RTP_target(k×S) / payout(k×S)

For 5× Diamond (payout = 1000×, target RTP contribution = 2.15%):

P_target(5×Diamond) = 0.0215 / 1000 = 0.0000215

Step 3: Translate target probabilities into weight products

P(5×S) = Ways(5×S) / Cycle

Ways(5×S) = P_target(5×S) × Cycle
          = 0.0000215 × 33,554,432
          = 721.4 ≈ 721

So we need approximately 721 ways to achieve 5× Diamond (including Wild substitutions) across all 33.5 million combinations.

Step 4: Decompose into per-reel weights

Now solve for weights. If Wilds contribute roughly W_total ways and pure Diamond contributes D_total:

Total ways with Wild ≈ Π(Di + Wi) - Π(Wi)  ≈  721

With the Wild counts (2,3,4,3,2) we already decided, we can estimate:

For Π(Di + Wi) to be ≈ 721 + Π(Wi):

Π(Wi) = 2×3×4×3×2 = 144
Π(Di + Wi) ≈ 865

We need to find (D1+2)(D2+3)(D3+4)(D4+3)(D5+2) ≈ 865.

Assuming a symmetric distribution (D1=D2=D4=D5, D3 slightly higher for the richer middle reel):

If D1=D2=D4=D5=1 and D3=2:

(1+2)(1+3)(2+4)(1+3)(1+2) = 3×4×6×4×3 = 864

This is exactly the Diamond weight distribution in our Awesome Slot PAR Sheet. The choice of D3=2 (double on the middle reel) is not arbitrary — it's the solution to the equation.

3.2 The Reel Weight Matrix

We can generalise this into a systematic matrix approach. Define:

W = weight matrix (S × R)
    Rows = symbols (0..10)
    Columns = reels (0..4)
    W[s][r] = count of symbol s on reel r

Constraints:

Σ_s W[s][r] = L_r  for each reel r   (strip length)
W[s][r] ≥ 0        for all s, r      (non-negative)
W[s][r] ∈ ℤ        for all s, r      (integer — can't have 1.7 of a symbol)

Objective:

Minimise |RTP(W) - RTP_target|
Subject to:
  |HitFreq(W) - HF_target| ≤ ε_HF
  |Variance(W) - Var_target| ≤ ε_Var
  |ScatterFreq(W) - SF_target| ≤ ε_SF

This is a constrained integer optimisation problem. There is no closed-form solution. The practical approaches are:

Manual iteration (small strips, experienced designer)

Gradient-guided search (automated, works well for medium strips)

Simulated annealing (robust, handles non-convex landscapes)

Genetic algorithms (good for multi-objective optimisation)

Mixed-Integer Programming (MIP solvers like GLPK or Gurobi)

We'll implement approaches 1 and 2 in C# below, with a conceptual walkthrough of annealing.

3.3 Practical Weight-Setting Rules of Thumb

Before getting to algorithms, here are the empirically derived rules that experienced designers use to set initial weights manually. These reduce the iteration space significantly.

Rule 1: The Premium Pyramid

Premium symbols (top 3–4 payers) should follow a roughly geometric distribution across reel positions:

Diamond: 1 on outer reels, 2 on middle reel
Ruby:    2 on outer reels, 2–3 on middle reel
Emerald: 2–3 on outer, 3 on middle

The factor between adjacent tiers is typically 1.5–2.5×.

Rule 2: The Wild Count Formula

A useful starting estimate for Wild count per reel:

Wild_count ≈ (RTP_Wild_contribution / 5) × Cycle / (payout_5Wild × Π_{i≠r} L_i)

Simplifying for symmetric strips of length L:

Wild_count_per_reel ≈ (target_wild_probability × L^5)^(1/5) / L^(4/5)
                    = (target_wild_probability)^(1/5) × L^(1/5)

For target_wild_probability = 0.00000429 (our 5×Wild target) and L=32:

Wild_count ≈ (0.00000429)^(1/5) × 32^(1/5)
           ≈ 0.214 × 2.0
           ≈ 0.43 per reel (too low for symmetry)

This suggests Wilds should be around 2 on outer reels and 3–4 on middle reels — consistent with our design. The formula is approximate but gives the right order of magnitude.

Rule 3: Low Symbol Balance

Low symbols (Ace, King, Queen, Jack) together should occupy 40–55% of the strip. They provide the hit frequency and the bulk of the "small win" volume.

Σ weights(low symbols, reel r) ≈ 0.45 × L_r

If the strip is 32 positions: ~14 positions for low symbols per reel.

Rule 4: The Middle Reel Amplification

The middle reel (R3) is structurally special because it participates in more winning combinations than any other single reel (appearing in all multi-symbol combos). Slightly inflating premium and Wild weights on R3 increases big-win frequency without proportionally increasing hit frequency.

Typical amplification: W[premium, R3] = W[premium, outer reels] × 1.5–2.0
                       W[Wild, R3]    = W[Wild, outer reels] × 1.5–2.0

Rule 5: Scatter Symmetry

Scatter symbols should have equal weight across all reels. Unequal Scatter weights create asymmetric trigger probabilities that are mathematically valid but perceptually unfair (and may be flagged by certification labs as unusual):

W[Scatter, R1] = W[Scatter, R2] = ... = W[Scatter, R5] = 1 or 2

Part IV. Iterative Reel Strip Design

4.1 The Manual Iteration Loop

Even with algorithmic tools, understanding the manual iteration process is essential — it builds the intuition that makes algorithmic approaches converge faster.

Set initial weights (using rules of thumb from Section 3.3)

Compute exact RTP and variance via full enumeration

Compare to targets — identify deltas

Adjust weights using marginal analysis

Repeat from step 2 until within tolerance

The key to efficient iteration is marginal analysis — knowing which weight change gives the best correction per unit of strip space used.

4.2 Marginal Analysis: The Weight Adjustment Table

For each symbol S on each reel R, the marginal RTP gain from adding one unit of weight is:

ΔRTP(S, R, +1) = RTP(W with W[S,R]+1) - RTP(W)
              ≈ Σ_k [∂P(k×S)/∂W[S,R] × payout(k×S)]  (for all k-of-a-kind)

This can be computed analytically:

csharp

public double MarginalRtpGain(int symbolId, int reelIndex, WeightMatrix W, GameConfig config)
{
    double gain = 0;
    int L = config.ReelSizes[reelIndex];

    foreach (var (k, payout) in config.PayTable[symbolId])
    {
        // Current probability of k-of-a-kind for this symbol
        double currentP = ComputeKofAKindProbability(symbolId, k, W, config);

        // Probability with one additional weight on reelIndex
        var wPlus1 = W.Clone();
        wPlus1[symbolId, reelIndex]++;
        double newP = ComputeKofAKindProbability(symbolId, k, wPlus1, config);

        gain += (newP - currentP) * payout;
    }

    return gain;
}

By computing this table for all symbols and reels, you get a priority list of weight adjustments sorted by RTP efficiency.

4.3 RTP Adjustment by Adding/Removing Weights

When your computed RTP is too high and you need to reduce it:

Current RTP: 67.2%, Target: 58%, Delta: -9.2%

Options (sorted by magnitude of RTP reduction per strip position):
  Remove 1× DIAMOND from R3:   ΔRTP = -0.35%
  Remove 1× RUBY from R3:      ΔRTP = -0.28%
  Remove 1× RUBY from R2:      ΔRTP = -0.21%
  Add 1× QUEEN to R1:          ΔRTP = +0.04%  (adds hit freq, doesn't help here)
  Remove 1× EMERALD from R3:   ΔRTP = -0.18%
  ...

To reduce by 9.2%, you might:

Remove 1× Diamond from R3 (−0.35%)

Remove 1× Ruby from R3 (−0.28%)

Remove 1× Ruby from R2 (−0.21%)

Reduce payouts on Emerald 3× from 20× to 18× (−0.4%)

... continuing until -9.2% is reached

4.4 Variance Adjustment

Variance is harder to adjust than RTP because it is dominated by rare large payouts. The practical levers:

To increase variance:

Increase top symbol weight slightly (increases P of large win)

Increase top payout (increases the squared term in variance formula)

Reduce hit frequency (concentrate value in fewer, larger events)

Move RTP budget from low symbols to Wild/Premium

To decrease variance:

Add low-paying symbols (increases hit frequency, flattens the distribution)

Reduce the spread between top and bottom payout

Reduce Wild weight (Wild contributes disproportionately to variance via premium combos)

Reduce or eliminate jackpot-tier payouts

A useful heuristic: variance scales approximately with the square of the top payout. Halving the top payout from 1000× to 500× reduces variance by roughly 75% for that combination (from payout² to 0.25 × payout²), assuming the weight is adjusted to maintain the same RTP contribution.


Part V. Full C# Implementation

5.1 Core Data Structures

/// <summary>
/// Represents the complete reel strip design for a single reel.
/// Immutable by design — every modification creates a new instance.
/// </summary>
public sealed class ReelStrip
{
    private readonly int[] _symbols;    // Raw symbol sequence (the "tape")
    private readonly int[] _counts;     // Precomputed: count[symbolId] = occurrences

    public int Length => _symbols.Length;
    public int SymbolCount { get; }

    public ReelStrip(int[] symbols, int totalSymbolTypes)
    {
        _symbols     = symbols.ToArray();
        SymbolCount  = totalSymbolTypes;
        _counts      = new int[totalSymbolTypes];
        foreach (int s in symbols)
            _counts[s]++;
    }

    public int GetSymbol(int stopPosition)
        => _symbols[((stopPosition % Length) + Length) % Length];

    public int GetWeight(int symbolId) => _counts[symbolId];

    public double GetProbability(int symbolId) => (double)_counts[symbolId] / Length;

    /// <summary>
    /// Returns a new ReelStrip with one additional occurrence of symbolId
    /// inserted at a random position within the strip.
    /// </summary>
    public ReelStrip WithAddedSymbol(int symbolId, int insertPosition = -1)
    {
        var newSymbols = new int[Length + 1];
        int pos = insertPosition < 0
            ? RandomNumberGenerator.GetInt32(Length + 1)
            : insertPosition;

        Array.Copy(_symbols, 0, newSymbols, 0, pos);
        newSymbols[pos] = symbolId;
        Array.Copy(_symbols, pos, newSymbols, pos + 1, Length - pos);

        return new ReelStrip(newSymbols, SymbolCount);
    }

    /// <summary>
    /// Returns a new ReelStrip with one occurrence of symbolId removed.
    /// Throws if symbolId has no occurrences.
    /// </summary>
    public ReelStrip WithRemovedSymbol(int symbolId)
    {
        int pos = Array.IndexOf(_symbols, symbolId);
        if (pos < 0)
            throw new InvalidOperationException(
                $"Symbol {symbolId} not found in strip.");

        var newSymbols = new int[Length - 1];
        Array.Copy(_symbols, 0, newSymbols, 0, pos);
        Array.Copy(_symbols, pos + 1, newSymbols, pos, Length - pos - 1);

        return new ReelStrip(newSymbols, SymbolCount);
    }

    public override string ToString()
        => $"[{string.Join(",", _symbols)}]";
}

/// <summary>
/// The complete weight matrix: symbol weights across all reels.
/// </summary>
public sealed class WeightMatrix
{
    private readonly int[,] _weights;   // [symbolId, reelIndex]
    public int SymbolCount { get; }
    public int ReelCount   { get; }

    public WeightMatrix(int symbolCount, int reelCount)
    {
        SymbolCount = symbolCount;
        ReelCount   = reelCount;
        _weights    = new int[symbolCount, reelCount];
    }

    public int this[int symbolId, int reelIndex]
    {
        get => _weights[symbolId, reelIndex];
        set => _weights[symbolId, reelIndex] = value;
    }

    public int GetStripLength(int reelIndex)
    {
        int total = 0;
        for (int s = 0; s < SymbolCount; s++)
            total += _weights[s, reelIndex];
        return total;
    }

    public WeightMatrix Clone()
    {
        var clone = new WeightMatrix(SymbolCount, ReelCount);
        Array.Copy(_weights, clone._weights, _weights.Length);
        return clone;
    }
}

5.2 Exact RTP Calculator

/// <summary>
/// Computes exact base game RTP via full combinatorial enumeration.
/// Handles Wild substitutions correctly.
/// </summary>
public sealed class ExactRtpCalculator
{
    private readonly GameConfig _config;

    public ExactRtpCalculator(GameConfig config) => _config = config;

    public RtpResult ComputeExact(WeightMatrix weights)
    {
        long   cycle        = ComputeCycle(weights);
        double totalRtp     = 0;
        double sumXSquared  = 0;
        long   hitWays      = 0;

        // Iterate over every payable symbol
        foreach (var (symbolId, payouts) in _config.PayTable)
        {
            if (symbolId == _config.WildSymbolId) continue; // handle separately

            foreach (var (k, payout) in payouts)
            {
                long  ways = ComputeWays(symbolId, k, weights);
                double p   = (double)ways / cycle;
                double rtp = p * payout;

                totalRtp    += rtp;
                sumXSquared += p * payout * payout;
                hitWays     += ways;
            }
        }

        // Wild-only combinations
        if (_config.PayTable.TryGetValue(_config.WildSymbolId, out var wildPayouts))
        {
            foreach (var (k, payout) in wildPayouts)
            {
                long  wildWays = ComputeWildOnlyWays(k, weights);
                double p       = (double)wildWays / cycle;
                double rtp     = p * payout;

                totalRtp    += rtp;
                sumXSquared += p * payout * payout;
                hitWays     += wildWays;
            }
        }

        double hitFrequency = (double)hitWays / cycle;
        double variance     = sumXSquared - totalRtp * totalRtp;

        return new RtpResult(
            Rtp:          totalRtp,
            HitFrequency: hitFrequency,
            Variance:     variance,
            StdDev:       Math.Sqrt(variance),
            Cycle:        cycle
        );
    }

    /// <summary>
    /// Computes the number of ways to achieve k-of-a-kind for symbolId,
    /// including Wild substitutions, with remaining reels being non-matching.
    /// </summary>
    public long ComputeWays(int symbolId, int k, WeightMatrix weights)
    {
        int n = _config.ReelCount;

        // Ways where reels 1..k show (symbolId or Wild) AND reels k+1..n
        // show neither symbolId nor Wild
        long waysMatch    = 1;
        long waysNoMatch  = 1;
        long wildOnlyMatch = 1;

        for (int r = 0; r < k; r++)
        {
            int matchCount = weights[symbolId, r] + weights[_config.WildSymbolId, r];
            waysMatch     *= matchCount;
            wildOnlyMatch *= weights[_config.WildSymbolId, r];
        }

        for (int r = k; r < n; r++)
        {
            int noMatchCount = weights.GetStripLength(r)
                               - weights[symbolId, r]
                               - weights[_config.WildSymbolId, r];
            waysNoMatch *= noMatchCount;
        }

        // Subtract pure-Wild combinations (they belong to the Wild combo category)
        long pureWildInMatchSection = wildOnlyMatch;
        long waysIncludingWildOnly  = waysMatch * waysNoMatch;
        long waysWithoutWildOnly    = (waysMatch - pureWildInMatchSection) * waysNoMatch;

        return waysWithoutWildOnly;
    }

    private long ComputeWildOnlyWays(int k, WeightMatrix weights)
    {
        int n = _config.ReelCount;
        long ways = 1;

        for (int r = 0; r < k; r++)
            ways *= weights[_config.WildSymbolId, r];

        for (int r = k; r < n; r++)
        {
            int nonWild = weights.GetStripLength(r) - weights[_config.WildSymbolId, r];
            ways *= nonWild;
        }

        return ways;
    }

    public long ComputeCycle(WeightMatrix weights)
    {
        long cycle = 1;
        for (int r = 0; r < _config.ReelCount; r++)
            cycle *= weights.GetStripLength(r);
        return cycle;
    }
}

public record RtpResult(
    double Rtp,
    double HitFrequency,
    double Variance,
    double StdDev,
    long   Cycle
)
{
    public void Print()
    {
        Console.WriteLine($"RTP:           {Rtp * 100:F4}%");
        Console.WriteLine($"Hit Frequency: {HitFrequency * 100:F2}%");
        Console.WriteLine($"Variance:      {Variance:F2}");
        Console.WriteLine($"Std Dev:       {StdDev:F2}× bet");
        Console.WriteLine($"Cycle:         {Cycle:N0}");
    }
}

5.3 Gradient-Guided Weight Solver

/// <summary>
/// Iteratively adjusts symbol weights across all reels to converge
/// toward target RTP, hit frequency, and variance.
/// Uses a greedy gradient-descent approach.
/// </summary>
public sealed class ReelWeightSolver
{
    private readonly GameConfig         _config;
    private readonly ExactRtpCalculator _calculator;
    private readonly SolverConfig       _solverConfig;

    public ReelWeightSolver(GameConfig config, SolverConfig solverConfig)
    {
        _config       = config;
        _calculator   = new ExactRtpCalculator(config);
        _solverConfig = solverConfig;
    }

    public SolverResult Solve(WeightMatrix initialWeights)
    {
        var current    = initialWeights.Clone();
        var history    = new List<IterationSnapshot>();
        int iterations = 0;

        Console.WriteLine("Starting reel weight solver...");
        Console.WriteLine($"Target RTP: {_solverConfig.TargetRtp * 100:F2}%");
        Console.WriteLine($"Tolerance:  ±{_solverConfig.RtpTolerance * 100:F3}%");
        Console.WriteLine();

        while (iterations < _solverConfig.MaxIterations)
        {
            var result = _calculator.ComputeExact(current);

            history.Add(new IterationSnapshot(iterations, result, current.Clone()));

            double rtpDelta  = result.Rtp - _solverConfig.TargetRtp;
            double hfDelta   = result.HitFrequency - _solverConfig.TargetHitFrequency;

            // Check convergence
            if (Math.Abs(rtpDelta) <= _solverConfig.RtpTolerance
                && Math.Abs(hfDelta) <= _solverConfig.HitFrequencyTolerance)
            {
                Console.WriteLine($"✓ Converged at iteration {iterations}");
                Console.WriteLine($"  Final RTP:  {result.Rtp * 100:F4}%");
                Console.WriteLine($"  Final HF:   {result.HitFrequency * 100:F2}%");
                return new SolverResult(current, result, history, true);
            }

            // Find the best weight adjustment
            var adjustment = FindBestAdjustment(current, result, rtpDelta, hfDelta);

            if (adjustment == null)
            {
                Console.WriteLine($"⚠ No beneficial adjustment found at iteration {iterations}");
                Console.WriteLine($"  Current RTP: {result.Rtp * 100:F4}%  " +
                                  $"(delta: {rtpDelta * 100:+F4;-F4}%)");
                break;
            }

            // Apply adjustment
            current = ApplyAdjustment(current, adjustment);

            if (iterations % 10 == 0)
            {
                Console.WriteLine($"  Iter {iterations:D4}: " +
                                  $"RTP={result.Rtp * 100:F4}%  " +
                                  $"HF={result.HitFrequency * 100:F2}%  " +
                                  $"σ={result.StdDev:F2}  " +
                                  $"Action={adjustment}");
            }

            iterations++;
        }

        var finalResult = _calculator.ComputeExact(current);
        return new SolverResult(current, finalResult, history, false);
    }

    private WeightAdjustment? FindBestAdjustment(
        WeightMatrix current,
        RtpResult    currentResult,
        double       rtpDelta,
        double       hfDelta)
    {
        // We need to DECREASE RTP (rtpDelta > 0) or INCREASE it (rtpDelta < 0)
        bool needDecrease = rtpDelta > 0;

        var candidates = new List<(WeightAdjustment adj, double score)>();

        for (int s = 0; s < _config.SymbolCount; s++)
        {
            // Never reduce Scatter below minimum
            if (s == _config.ScatterSymbolId &&
                current.GetStripLength(0) <= _config.MinScatterPerReel + 1) continue;

            for (int r = 0; r < _config.ReelCount; r++)
            {
                int stripLen = current.GetStripLength(r);

                // Try adding one symbol
                if (!needDecrease || IsLowValueSymbol(s))
                {
                    var addResult = _calculator.ComputeExact(
                        current.WithAdjustment(s, r, +1));
                    double addRtpDelta = addResult.Rtp - currentResult.Rtp;
                    double score = ScoreAdjustment(addRtpDelta, rtpDelta,
                        addResult.HitFrequency - currentResult.HitFrequency, hfDelta);

                    if (score > 0)
                        candidates.Add((new WeightAdjustment(s, r, +1), score));
                }

                // Try removing one symbol (must keep at least minimum)
                if (current[s, r] > GetMinWeight(s, r))
                {
                    var removeResult = _calculator.ComputeExact(
                        current.WithAdjustment(s, r, -1));
                    double removeRtpDelta = removeResult.Rtp - currentResult.Rtp;
                    double score = ScoreAdjustment(removeRtpDelta, rtpDelta,
                        removeResult.HitFrequency - currentResult.HitFrequency, hfDelta);

                    if (score > 0)
                        candidates.Add((new WeightAdjustment(s, r, -1), score));
                }
            }
        }

        if (candidates.Count == 0) return null;

        // Return the highest-scoring adjustment
        candidates.Sort((a, b) => b.score.CompareTo(a.score));
        return candidates[0].adj;
    }

    /// <summary>
    /// Scores an adjustment based on how well it moves both RTP and hit frequency
    /// toward their targets. Higher is better.
    /// </summary>
    private double ScoreAdjustment(
        double rtpChange,   double rtpDelta,
        double hfChange,    double hfDelta)
    {
        // Primary: RTP correction
        double rtpScore = rtpChange * Math.Sign(-rtpDelta);  // positive if moving toward target
        if (rtpScore <= 0) return -1;  // wrong direction — discard

        // Secondary: Hit frequency correction (weighted less)
        double hfScore = hfChange * Math.Sign(-hfDelta) * 0.1;

        return rtpScore + hfScore;
    }

    private bool IsLowValueSymbol(int symbolId)
        => _config.GetSymbolTier(symbolId) == SymbolTier.Low;

    private int GetMinWeight(int symbolId, int reelIndex)
    {
        if (symbolId == _config.WildSymbolId)    return 1;
        if (symbolId == _config.ScatterSymbolId) return 1;
        return 0;
    }

    private WeightMatrix ApplyAdjustment(WeightMatrix w, WeightAdjustment adj)
    {
        var result = w.Clone();
        result[adj.SymbolId, adj.ReelIndex] += adj.Delta;
        return result;
    }
}

public record WeightAdjustment(int SymbolId, int ReelIndex, int Delta)
{
    public override string ToString()
        => $"{(Delta > 0 ? "Add" : "Remove")} symbol {SymbolId} on reel {ReelIndex}";
}

public record SolverConfig(
    double TargetRtp,
    double RtpTolerance,
    double TargetHitFrequency,
    double HitFrequencyTolerance,
    double TargetVariance,
    int    MaxIterations = 500
);

public record SolverResult(
    WeightMatrix                Weights,
    RtpResult                   FinalResult,
    List<IterationSnapshot>     History,
    bool                        Converged
);

public record IterationSnapshot(int Iteration, RtpResult Result, WeightMatrix Weights);

5.4 Simulated Annealing for Reel Optimisation

When the gradient solver gets stuck in a local minimum (which happens with complex pay tables), simulated annealing can escape:

/// <summary>
/// Simulated annealing solver for reel weight optimisation.
/// More robust than gradient descent for complex multi-objective problems.
/// Slower, but finds better solutions when the objective landscape is non-convex.
/// </summary>
public sealed class AnnealingReelSolver
{
    private readonly GameConfig         _config;
    private readonly ExactRtpCalculator _calculator;
    private readonly SolverConfig       _solverConfig;

    public WeightMatrix Solve(WeightMatrix initial, int maxIterations = 50_000)
    {
        var    current     = initial.Clone();
        var    best        = initial.Clone();
        double currentCost = ComputeCost(current);
        double bestCost    = currentCost;

        double temperature = 1.0;
        double cooling     = Math.Pow(0.0001 / temperature, 1.0 / maxIterations);
        // Temperature cools from 1.0 to 0.0001 over maxIterations

        Console.WriteLine($"Annealing: {maxIterations} iterations, " +
                          $"initial cost={currentCost:F6}");

        for (int i = 0; i < maxIterations; i++)
        {
            // Generate a random neighbour
            var neighbour = GenerateNeighbour(current);
            double neighbourCost = ComputeCost(neighbour);

            // Accept or reject based on Metropolis criterion
            double delta = neighbourCost - currentCost;
            bool accept = delta < 0  // always accept improvements
                || RandomNumberGenerator.GetInt32(10000) < 10000 * Math.Exp(-delta / temperature);

            if (accept)
            {
                current     = neighbour;
                currentCost = neighbourCost;

                if (currentCost < bestCost)
                {
                    best     = current.Clone();
                    bestCost = currentCost;
                }
            }

            temperature *= cooling;

            if (i % 5000 == 0)
            {
                Console.WriteLine($"  Iter {i:D6}: T={temperature:F6}  " +
                                  $"cost={currentCost:F6}  best={bestCost:F6}");
            }
        }

        Console.WriteLine($"Annealing complete. Best cost: {bestCost:F6}");
        var result = _calculator.ComputeExact(best);
        Console.WriteLine($"Final RTP: {result.Rtp * 100:F4}%  " +
                          $"HF: {result.HitFrequency * 100:F2}%");

        return best;
    }

    /// <summary>
    /// Computes a scalar cost value.
    /// Zero = perfect solution. Higher = worse.
    /// Combines weighted penalties for RTP, hit frequency, and variance deviations.
    /// </summary>
    private double ComputeCost(WeightMatrix weights)
    {
        var r = _calculator.ComputeExact(weights);

        double rtpPenalty = Math.Pow((r.Rtp - _solverConfig.TargetRtp) /
                                      _solverConfig.RtpTolerance, 2);

        double hfPenalty  = Math.Pow((r.HitFrequency - _solverConfig.TargetHitFrequency) /
                                      _solverConfig.HitFrequencyTolerance, 2);

        double varPenalty = _solverConfig.TargetVariance > 0
            ? Math.Pow((r.Variance - _solverConfig.TargetVariance) /
                        _solverConfig.TargetVariance, 2) * 0.3
            : 0;

        // Penalise constraint violations harder than objective distance
        return rtpPenalty * 10 + hfPenalty * 5 + varPenalty;
    }

    private WeightMatrix GenerateNeighbour(WeightMatrix current)
    {
        var    neighbour = current.Clone();
        int    attempt   = 0;
        const int maxAttempts = 20;

        while (attempt++ < maxAttempts)
        {
            int s = RandomNumberGenerator.GetInt32(_config.SymbolCount);
            int r = RandomNumberGenerator.GetInt32(_config.ReelCount);

            // Randomly add or remove
            bool add = RandomNumberGenerator.GetInt32(2) == 0;

            if (add)
            {
                neighbour[s, r]++;
                return neighbour;
            }
            else if (neighbour[s, r] > GetMinWeight(s))
            {
                neighbour[s, r]--;
                return neighbour;
            }
        }

        // Fallback: just add a low-value symbol somewhere
        int lowSym = _config.GetLowValueSymbolId();
        int reel   = RandomNumberGenerator.GetInt32(_config.ReelCount);
        neighbour[lowSym, reel]++;
        return neighbour;
    }

    private int GetMinWeight(int symbolId)
    {
        if (symbolId == _config.WildSymbolId)    return 1;
        if (symbolId == _config.ScatterSymbolId) return 1;
        return 0;
    }
}

5.5 Converting the Weight Matrix to a Physical Reel Strip

Once weights are finalised, they need to be materialised into an actual ordered sequence. The arrangement within the strip affects near-miss frequency — a design choice.

/// <summary>
/// Converts a weight matrix into physical reel strips.
/// Supports multiple symbol placement strategies.
/// </summary>
public sealed class ReelStripGenerator
{
    public enum PlacementStrategy
    {
        /// <summary>
        /// Symbols are placed uniformly — each symbol appears at
        /// evenly spaced intervals. Minimises clustering.
        /// </summary>
        Uniform,

        /// <summary>
        /// Symbols are shuffled randomly. Maximum entropy.
        /// Minimises near-misses for premium symbols.
        /// </summary>
        Random,

        /// <summary>
        /// High-value symbols are separated by low-value symbols.
        /// Creates near-misses: premiums "almost" align on adjacent reels.
        /// </summary>
        NearMissOptimised,

        /// <summary>
        /// Scatter symbols are evenly spaced — prevents visual
        /// clustering that would imply unfair trigger probability.
        /// </summary>
        ScatterEvenSpaced
    }

    public ReelStrip GenerateStrip(
        int reelIndex,
        WeightMatrix weights,
        int totalSymbolTypes,
        PlacementStrategy strategy = PlacementStrategy.Uniform)
    {
        int stripLength = weights.GetStripLength(reelIndex);
        var symbolPool  = BuildSymbolPool(reelIndex, weights, totalSymbolTypes);

        int[] arrangement = strategy switch
        {
            PlacementStrategy.Uniform          => ArrangeUniform(symbolPool, stripLength),
            PlacementStrategy.Random           => ArrangeRandom(symbolPool),
            PlacementStrategy.NearMissOptimised => ArrangeNearMiss(symbolPool, weights, reelIndex),
            PlacementStrategy.ScatterEvenSpaced => ArrangeScatterEvenSpaced(symbolPool, weights, reelIndex),
            _ => ArrangeRandom(symbolPool)
        };

        return new ReelStrip(arrangement, totalSymbolTypes);
    }

    private List<int> BuildSymbolPool(int reelIndex, WeightMatrix weights, int totalSymbols)
    {
        var pool = new List<int>();
        for (int s = 0; s < totalSymbols; s++)
            for (int i = 0; i < weights[s, reelIndex]; i++)
                pool.Add(s);
        return pool;
    }

    /// <summary>
    /// Uniform spacing: symbol s appears at positions
    /// round(stripLen * (k + 0.5) / count[s]) for k = 0..count[s]-1.
    /// Minimises variance in spacing between identical symbols.
    /// </summary>
    private int[] ArrangeUniform(List<int> symbolPool, int stripLength)
    {
        var result = new int[stripLength];
        var countPerSymbol = symbolPool
            .GroupBy(s => s)
            .ToDictionary(g => g.Key, g => g.Count());

        // Use a Bresenham-like algorithm to distribute each symbol evenly
        var positions = new List<(int position, int symbolId)>();

        foreach (var (sym, count) in countPerSymbol)
        {
            for (int k = 0; k < count; k++)
            {
                int pos = (int)Math.Round((double)stripLength * (k + 0.5) / count);
                positions.Add((pos % stripLength, sym));
            }
        }

        // Sort by position and resolve conflicts
        positions.Sort((a, b) => a.position.CompareTo(b.position));

        // Fill result array — handle position conflicts by shifting
        var occupied = new HashSet<int>();
        foreach (var (pos, sym) in positions)
        {
            int finalPos = pos;
            while (occupied.Contains(finalPos))
                finalPos = (finalPos + 1) % stripLength;

            result[finalPos] = sym;
            occupied.Add(finalPos);
        }

        // Fill any remaining gaps with the most common symbol (low-value)
        int defaultSymbol = countPerSymbol.MaxBy(kv => kv.Value).Key;
        for (int i = 0; i < stripLength; i++)
            if (!occupied.Contains(i))
                result[i] = defaultSymbol;

        return result;
    }

    private int[] ArrangeRandom(List<int> symbolPool)
    {
        // Fisher-Yates shuffle using CSPRNG
        var pool = symbolPool.ToArray();
        for (int i = pool.Length - 1; i > 0; i--)
        {
            int j = RandomNumberGenerator.GetInt32(i + 1);
            (pool[i], pool[j]) = (pool[j], pool[i]);
        }
        return pool;
    }

    /// <summary>
    /// Near-miss optimised layout:
    /// Premium symbols are placed so that when one reel lands a premium,
    /// adjacent reels are "one stop away" from premiums — maximising the
    /// frequency of visually near-miss outcomes.
    /// This is a widely used and fully legal technique (it only affects
    /// visual presentation, not the underlying probability distribution).
    /// </summary>
    private int[] ArrangeNearMiss(List<int> symbolPool, WeightMatrix weights, int reelIndex)
    {
        int stripLength    = symbolPool.Count;
        var result         = new int[stripLength];
        int premiumSymbol  = GetPremiumSymbol(weights, reelIndex);
        int premiumCount   = weights[premiumSymbol, reelIndex];

        // Place premium symbols first at evenly spaced intervals
        var premiumPositions = new List<int>();
        for (int k = 0; k < premiumCount; k++)
            premiumPositions.Add((int)Math.Round((double)stripLength * k / premiumCount));

        // Place low-value symbols in the two positions flanking each premium
        // This ensures that stopping just before or after a premium shows
        // the premium in the visible window (but not on the winning line)
        var remainingPool = symbolPool
            .Where(s => s != premiumSymbol)
            .ToList();

        var occupied = new HashSet<int>(premiumPositions);
        foreach (int premPos in premiumPositions)
            result[premPos] = premiumSymbol;

        // Fill remaining positions randomly from the remaining pool
        var shuffled = ArrangeRandom(remainingPool);
        int poolIdx  = 0;
        for (int i = 0; i < stripLength; i++)
        {
            if (!occupied.Contains(i))
                result[i] = shuffled[poolIdx++];
        }

        return result;
    }

    private int[] ArrangeScatterEvenSpaced(
        List<int> symbolPool, WeightMatrix weights, int reelIndex)
    {
        // Same as Uniform but ensures Scatter is never adjacent to itself
        return ArrangeUniform(symbolPool, symbolPool.Count);
    }

    private int GetPremiumSymbol(WeightMatrix weights, int reelIndex)
    {
        // Returns the symbol ID of the lowest-count non-Wild, non-Scatter symbol
        int minCount = int.MaxValue;
        int result   = 0;
        for (int s = 0; s < weights.SymbolCount; s++)
        {
            if (weights[s, reelIndex] > 0 &&
                weights[s, reelIndex] < minCount)
            {
                minCount = weights[s, reelIndex];
                result   = s;
            }
        }
        return result;
    }
}

Part VI. Near-Miss Engineering

6.1 What Near-Misses Are and Why They Work

A near-miss is an outcome where the player almost wins a significant prize but doesn't — for example, two Diamonds on the payline and one Diamond just above or below the payline on the third reel.

Near-misses are psychologically powerful. Research in behavioural psychology consistently shows that near-misses activate the same reward circuitry as actual wins, sustaining engagement and encouraging continued play. This is why they are a deliberate design tool in slot development.

Critically: near-misses are legal and legitimate as long as they do not misrepresent the probability of winning. The outcome (no win) must be correctly reflected in the RTP. What you are doing is choosing which type of non-win the player sees. A near-miss is still a loss — its probability is fully accounted for in the mathematics.

What is illegal is artificially inflating near-miss frequency beyond what the mathematics naturally produces — for instance, programming the game to preferentially select stop positions where premium symbols are one row away from the payline more often than their true probability would suggest. This constitutes a misleading representation of the game's win probability and violates regulations in virtually every jurisdiction.

The correct approach: engineer the physical layout of the reel strip (which symbols are adjacent to each other) to maximise natural near-misses, without altering the stop selection probability.

6.2 Measuring Near-Miss Frequency

public class NearMissAnalyser
{
    private readonly GameConfig _config;

    /// <summary>
    /// Counts near-miss outcomes — spins where 2 or more premium symbols
    /// appear on the payline AND at least one premium symbol appears
    /// adjacent to (but not on) the payline on another reel.
    /// </summary>
    public NearMissStats Analyse(
        ReelStrip[] strips,
        int[][]     paylines,
        int         premiumThreshold)
    {
        long totalSpins    = 1;
        long nearMissCount = 0;

        foreach (var strip in strips)
            totalSpins *= strip.Length;

        var reelSizes = strips.Select(s => s.Length).ToArray();

        foreach (var stops in EnumerateAllStops(reelSizes))
        {
            // Check each payline for near-miss
            foreach (var payline in paylines)
            {
                if (IsNearMiss(stops, payline, strips, premiumThreshold))
                {
                    nearMissCount++;
                    break; // Count spin once even if multiple lines near-miss
                }
            }
        }

        return new NearMissStats(
            TotalSpins:       totalSpins,
            NearMissCount:    nearMissCount,
            NearMissRate:     (double)nearMissCount / totalSpins
        );
    }

    private bool IsNearMiss(
        int[] stops, int[] payline,
        ReelStrip[] strips, int premiumThreshold)
    {
        int onLineCount    = 0;
        int adjacentCount  = 0;

        for (int col = 0; col < stops.Length; col++)
        {
            int onLineRow  = payline[col];
            int onLineSym  = strips[col].GetSymbol(stops[col] + onLineRow - 1);
            bool onLine    = onLineSym <= premiumThreshold;

            if (onLine)
            {
                onLineCount++;
            }
            else
            {
                // Check rows above and below the payline row
                for (int rowOffset = -1; rowOffset <= 1; rowOffset += 2)
                {
                    int adjRow = onLineRow + rowOffset;
                    if (adjRow < 0 || adjRow >= _config.VisibleRows) continue;
                    int adjSym = strips[col].GetSymbol(stops[col] + adjRow - 1);
                    if (adjSym <= premiumThreshold)
                        adjacentCount++;
                }
            }
        }

        // Near-miss: 2+ premiums on line, at least 1 adjacent premium
        return onLineCount >= 2 && adjacentCount >= 1;
    }

    private IEnumerable<int[]> EnumerateAllStops(int[] reelSizes)
    {
        int[] stops = new int[reelSizes.Length];

        while (true)
        {
            yield return stops.ToArray();

            int pos = reelSizes.Length - 1;
            while (pos >= 0)
            {
                stops[pos]++;
                if (stops[pos] < reelSizes[pos]) break;
                stops[pos] = 0;
                pos--;
            }
            if (pos < 0) yield break;
        }
    }
}

public record NearMissStats(long TotalSpins, long NearMissCount, double NearMissRate)
{
    public void Print()
    {
        Console.WriteLine($"Near-miss rate: {NearMissRate * 100:F3}% " +
                          $"(1 in {1.0 / NearMissRate:F0} spins)");
    }
}

Part VII. Complete Worked Example

7.1 Starting Point

We use the our Awesome Slot from previous articles. Our initial weight matrix (from manual design using the rules of thumb):

Symbol     | R1  R2  R3  R4  R5 | Strip sum = 32 per reel
───────────┼────────────────────┤
WILD   (1) |  2   3   4   3   2 |
SCATTER(2) |  1   1   1   1   1 |
DIAMOND(3) |  1   1   2   1   1 |
RUBY   (4) |  2   2   2   2   2 |
EMERALD(5) |  2   2   3   2   2 |
GOLD   (6) |  3   3   3   3   3 |
SILVER (7) |  3   4   4   4   3 |
ACE    (8) |  4   4   4   4   4 |
KING   (9) |  4   4   3   4   4 |
QUEEN (10) |  6   5   4   5   6 |
JACK  (11) |  4   3   2   3   4 |
───────────┼────────────────────┤
Total      | 32  32  32  32  32 |

7.2 Running the Solver

var config = new GameConfig
{
    ReelCount         = 5,
    ReelSizes         = new[] { 32, 32, 32, 32, 32 },
    SymbolCount       = 11,
    WildSymbolId      = 0,       // 0-indexed internally
    ScatterSymbolId   = 1,
    PayTable          = CrystalForgePayTable.Build(),
    MinScatterPerReel = 1
};

var solverConfig = new SolverConfig(
    TargetRtp:             0.58,      // 58% base game RTP
    RtpTolerance:          0.002,     // ±0.2%
    TargetHitFrequency:    0.30,      // 30%
    HitFrequencyTolerance: 0.02,      // ±2%
    TargetVariance:        110.0      // medium-high
);

var initialWeights = WeightMatrix.FromArray(new int[,]
{
//   R1  R2  R3  R4  R5
    { 2,  3,  4,  3,  2 },  // WILD
    { 1,  1,  1,  1,  1 },  // SCATTER
    { 1,  1,  2,  1,  1 },  // DIAMOND
    { 2,  2,  2,  2,  2 },  // RUBY
    { 2,  2,  3,  2,  2 },  // EMERALD
    { 3,  3,  3,  3,  3 },  // GOLD
    { 3,  4,  4,  4,  3 },  // SILVER
    { 4,  4,  4,  4,  4 },  // ACE
    { 4,  4,  3,  4,  4 },  // KING
    { 6,  5,  4,  5,  6 },  // QUEEN
    { 4,  3,  2,  3,  4 },  // JACK
});

// First pass: compute initial state
var calculator    = new ExactRtpCalculator(config);
var initialResult = calculator.ComputeExact(initialWeights);
Console.WriteLine("=== Initial State ===");
initialResult.Print();

// Output:
// RTP:           65.03%   ← too high, target 58%
// Hit Frequency: 30.41%   ← on target
// Variance:      106.4    ← close to target
// Std Dev:       10.31
// Cycle:         33,554,432

// Run gradient solver
var solver = new ReelWeightSolver(config, solverConfig);
var result = solver.Solve(initialWeights);

Console.WriteLine("\n=== Solver Result ===");
result.FinalResult.Print();
Console.WriteLine($"Converged: {result.Converged}");

// Expected output after ~80 iterations:
// RTP:           57.98%   ✓ (within ±0.2% of 58%)
// Hit Frequency: 30.12%   ✓ (within ±2% of 30%)
// Variance:      108.3    ✓ (within tolerance)
// Std Dev:       10.41
// Converged: True

7.3 Generating Physical Strips from Final Weights

var generator = new ReelStripGenerator();

ReelStrip[] finalStrips = new ReelStrip[5];
for (int r = 0; r < 5; r++)
{
    finalStrips[r] = generator.GenerateStrip(
        reelIndex: r,
        weights:   result.Weights,
        totalSymbolTypes: config.SymbolCount,
        strategy:  ReelStripGenerator.PlacementStrategy.NearMissOptimised
    );

    Console.WriteLine($"\nReel {r + 1} ({finalStrips[r].Length} positions):");
    Console.WriteLine(finalStrips[r].ToString());

    // Verify weights preserved
    for (int s = 0; s < config.SymbolCount; s++)
    {
        int expected = result.Weights[s, r];
        int actual   = finalStrips[r].GetWeight(s);
        Debug.Assert(expected == actual,
            $"Weight mismatch! Symbol {s} on Reel {r}: expected {expected}, got {actual}");
    }
}

Console.WriteLine("\n✓ All weight integrity checks passed.");

Part VIII. Validation and Quality Assurance

8.1 Verification Checklist for Reel Strips

Before finalising any reel strip design, run through this checklist:

Strip length matches design spec for each reel

All symbol counts (weights) match the weight matrix

No symbol with weight 0 appears in the strip

Wild count meets minimum requirements per reel

Scatter count is consistent across all reels

Exact RTP computation matches target within ±0.2%

Hit Frequency is within target range

Variance / Std Dev matches volatility profile

Scatter trigger frequency matches bonus design spec

No two high-value symbols are adjacent (near-miss consideration)

Strip wraps correctly (no out-of-bounds errors in game logic)

Simulation of 10M spins confirms analytical results within ±0.1%

8.2 Regression Testing Reel Strips

[TestClass]
public class ReelStripRegressionTests
{
    private static readonly GameConfig _config = CrystalForgeConfig.Build();
    private static readonly ReelStrip[] _strips = CrystalForgeStrips.Load();

    [TestMethod]
    public void BaseRtp_ShouldBe58Percent_WithinTolerance()
    {
        var calculator = new ExactRtpCalculator(_config);
        var weights    = WeightMatrix.FromStrips(_strips, _config.SymbolCount);
        var result     = calculator.ComputeExact(weights);

        Assert.IsTrue(
            Math.Abs(result.Rtp - 0.58) <= 0.002,
            $"Base RTP {result.Rtp:P4} outside ±0.2% of target 58%"
        );
    }

    [TestMethod]
    public void HitFrequency_ShouldBe30Percent_WithinTolerance()
    {
        var calculator = new ExactRtpCalculator(_config);
        var weights    = WeightMatrix.FromStrips(_strips, _config.SymbolCount);
        var result     = calculator.ComputeExact(weights);

        Assert.IsTrue(
            Math.Abs(result.HitFrequency - 0.30) <= 0.03,
            $"Hit Frequency {result.HitFrequency:P2} outside ±3% of target 30%"
        );
    }

    [TestMethod]
    public void ScatterTriggerFrequency_ShouldMatch_Specification()
    {
        // 3+ Scatters anywhere on grid should trigger approximately 1 in 145 spins
        var analyser  = new ScatterAnalyser(_config);
        double triggerProb = analyser.ComputeTriggerProbability(_strips);

        Assert.IsTrue(
            Math.Abs(1.0 / triggerProb - 145) <= 15,
            $"Scatter trigger freq 1 in {1.0/triggerProb:F0}, expected 1 in 145 ±15"
        );
    }

    [TestMethod]
    public void StripWeights_ShouldBeConsistentWithDesignMatrix()
    {
        var designMatrix = CrystalForgeDesign.WeightMatrix;

        for (int r = 0; r < _strips.Length; r++)
        for (int s = 0; s < _config.SymbolCount; s++)
        {
            Assert.AreEqual(
                designMatrix[s, r],
                _strips[r].GetWeight(s),
                $"Symbol {s} on Reel {r}: design={designMatrix[s,r]}, " +
                $"actual={_strips[r].GetWeight(s)}"
            );
        }
    }
}

Part IX. Common Mistakes in Reel Strip Design

Mistake 1: Assuming Symmetric Reels Are Optimal

// Common misconception: identical strips on all reels simplifies math
// Reality: symmetric strips produce suboptimal volatility profiles
// because P(k×S) = (w/L)^k — the probability curve is a perfect power law
// with no room to shape the 3-of-a-kind vs 5-of-a-kind ratio independently

// Better: deliberately vary weights across reels
// This lets you tune the k=3 and k=5 probabilities somewhat independently

A standard asymmetric technique: give the middle reel more Wilds and Premiums. This amplifies the probability of completing 5-of-a-kind (which passes through the middle reel) more than it amplifies 3-of-a-kind (which doesn't need the middle reel). Net effect: higher variance at the same RTP.

Mistake 2: Forgetting the Three-Row Window for Scatter

The most frequent calculation error. Always use:

P(Scatter visible, reel R) = (count × 3) / stripLength

Not count/stripLength.

Mistake 3: Integer Rounding Errors Accumulating Across Adjustments

When running many iterations of manual or algorithmic adjustment, rounding errors in the weight counts can accumulate. After every adjustment, recompute the RTP from scratch — never carry forward a "delta RTP" estimate through multiple steps.

Mistake 4: Optimising RTP Without Fixing Strip Length First

If you allow the solver to increase strip length arbitrarily to improve RTP precision, you can end up with strips of 89, 73, 127 positions — mathematically fine but a nightmare to audit, certify, and explain to a game reviewer. Fix strip lengths first, then solve within that constraint.

Mistake 5: Near-Miss Engineering That Crosses Into Manipulation

Legal near-miss: arrange the physical order of symbols on the strip to maximise natural near-misses, without changing stop selection probability.

Illegal: bias the random stop selection toward positions where Diamonds appear just above or below the payline, making near-misses more frequent than the probability distribution would naturally produce. This is a regulatory violation in every licensed jurisdiction. The RNG must be blind to the aesthetic value of any particular stop position.


Summary

Reel strip design is the engineering discipline of translating mathematical intent — a target RTP, a volatility profile, a frequency budget — into a concrete table of symbol counts that will be stamped into the game's source code and certified by a testing laboratory.

The key principles to carry forward:

Weights are probabilities. Every symbol count is a probability lever. The marginal return diminishes: adding the second Diamond matters far more than adding the sixth.

Strip length is resolution. Longer strips allow finer probability tuning but increase cycle size. Design the strip length to be the smallest value that gives sufficient resolution for your target probabilities.

The weight matrix is the master document. Physical strip arrangement is a presentation decision that comes after weights are locked. Arrangement affects near-miss frequency; weights determine the mathematics.

The three-row window triples Scatter probability. This is the single most common calculation error in PAR Sheets. Always account for visible rows when computing Scatter trigger probability.

Near-miss engineering is legal when done correctly. Arrange strips to maximise natural near-misses. Never bias stop selection to favour near-miss positions.

Verify twice: analytically and by simulation. Analytical enumeration gives exact RTP. A 10M-spin simulation confirms the implementation matches the design. Both must agree within 0.1%.

Neon Royale use cookies for technical purposes, functionality and measurement.

Policy