Rock-Paper-Scissors engine
After preparing this recent answer it occurred to me that I'd already written something like it.  Here's my review of this code.

Make the class more useful

Right now the only thing we can do with the RockPaperScissors class is to get a new one via random or print the results of a contest.  If we wanted to do something else, such as collect some statistics, there are pieces missing, such as the ability to get a printable version of the class.  Here's one way to add that:

friend std::ostream&amp; operator&lt;&lt;(std::ostream&amp; out, const RockPaperScissors &amp;rps);


Now we can gather some statistics like this:

int main()
{
    std::unordered_map&lt;std::string_view, unsigned&gt; responses;
    std::unordered_map&lt;std::string_view, unsigned&gt; results;
    constexpr unsigned max_trials{300'000'000};
    for (unsigned trials = max_trials; trials; --trials) {
        auto a = RockPaperScissors::random();
        auto b = RockPaperScissors::random();
        ++responses[static_cast&lt;std::string_view&gt;(a)];
        ++responses[static_cast&lt;std::string_view&gt;(b)];
        ++results[a.vs(b)];
    }
    for (const auto&amp; resp: responses) {
        std::cout &lt;&lt; std::setw(16) &lt;&lt; resp.first &lt;&lt; '\t' &lt;&lt; resp.second 
            &lt;&lt; '\t' &lt;&lt; static_cast&lt;double&gt;(resp.second)/max_trials/2
            &lt;&lt; '\n';
    }
    for (const auto&amp; resp: results) {
        std::cout &lt;&lt; std::setw(16) &lt;&lt; resp.first &lt;&lt; '\t' &lt;&lt; resp.second 
            &lt;&lt; '\t' &lt;&lt; static_cast&lt;double&gt;(resp.second)/max_trials
            &lt;&lt; '\n';
    }
}


This gives a report something like this:

       Paper    200002179   0.333337
    Scissors    199987250   0.333312
        Rock    200010571   0.333351
         WIN    66662424    0.222208
         TIE    100003629   0.333345
        LOSE    133333947   0.444446


Prefer open functions to class functions

The current code has this member function:

RockPaperScissors RockPaperScissors::random() {
    static std::random_device rd;
    static std::mt19937 gen{rd()};
    static std::uniform_int_distribution&lt;&gt; dis(0, RockPaperScissors::words.size()-1);
    std::size_t which{static_cast&lt;std::size_t&gt;(dis(gen))};
    return RockPaperScissors{which};
}


However, selecting a single item at random from within a collection is a very common need.  A more generally useful way to write this might be this:

template &lt;typename Iter&gt;
Iter random_one(Iter begin, const Iter&amp; end) {
    static std::random_device rd;
    static std::mt19937 gen{rd()};
    static std::uniform_int_distribution&lt;&gt; dis(0, std::distance(begin, end) - 1);
    std::advance(begin, dis(gen));
    return begin;
}


Now we can get an iterator that points to a particular item in any collection.  (To make this template more durable, we'd actually probably want to do some validation of both the Iter type and the values passed, but this conveys the idea.) We could use that with no further modifications as in this:

RockPaperScissors RockPaperScissors::random() {
    auto which = std::distance(words.begin(), random_one(words.begin(), words.end()));
    return RockPaperScissors{which};
}


But that's a bit odd because we are converting an iterator to an ordinal.  That leads to the next suggestion.

Store an iterator within the class

We can simplify some of the code for this implementation by changing the definition of choice within the class to be an iterator. 

using iter = decltype(words.begin());
iter choice;  


Now the constructor can use the templated function above very simply:

RockPaperScissors::RockPaperScissors() : choice{random_one(words.begin(), words.end())}
{
}


A few other changes are needed, but mostly they simplify the code.  

Also note that now the random function is no longer needed.