A slot-style front end can become surprisingly fragile once animation, delayed reel stops, buttons, and feature screens all compete to change the same interface. In this tutorial, Slot Help Win is only a contextual reference for slot terminology; the state machine below is an independent synthetic demo and does not describe the site’s implementation. We will use TypeScript to make conflicting actions impossible rather than merely hoping every click handler behaves.
The demo has no wagering, balance, payout, random-number generator, or real-money logic. Its “outcomes” are fixed test objects. The engineering problem is strictly UI coordination: a spin begins, reels animate, stopping starts, three reel-stop events arrive, a result appears, an optional feature can open, and the interface eventually resets.
That sequence is a good fit for an explicit finite-state machine because each state defines which events are legal right now.
Model the states before the buttons
Start by naming the states the interface can actually occupy. Avoid a pile of booleans such as isSpinning, isStopping, hasResult, and showFeature. Several booleans can accidentally describe impossible combinations. A discriminated union makes those combinations unrepresentable.
export type Outcome = {
reels: readonly [string, string, string];
feature?: "free-spins";
};
export type SpinState =
| { type: "idle" }
| { type: "spinning"; stopped: number }
| { type: "stopping"; stopped: number }
| { type: "result"; outcome: Outcome }
| { type: "feature"; outcome: Outcome; feature: "free-spins" };
export type Event =
| { type: "SPIN" }
| { type: "BEGIN_STOPPING" }
| { type: "REEL_STOPPED" }
| { type: "RESULT_READY"; outcome: Outcome }
| { type: "OPEN_FEATURE" }
| { type: "RESET" };
export const initialState: SpinState = { type: "idle" };
The useful part is not the names themselves. It is the boundary they create. idle cannot accidentally contain a result, while result must contain an outcome. The compiler now helps enforce facts that would otherwise exist only in comments.
Put transition guards in one function
The transition function is the authority for legal movement. Invalid events simply return the current state.
export function transition(state: SpinState, event: Event): SpinState {
switch (state.type) {
case "idle":
return event.type === "SPIN"
? { type: "spinning", stopped: 0 }
: state;
case "spinning":
return event.type === "BEGIN_STOPPING"
? { type: "stopping", stopped: state.stopped }
: state;
case "stopping":
if (event.type === "REEL_STOPPED") {
return {
type: "stopping",
stopped: Math.min(3, state.stopped + 1),
};
}
if (event.type === "RESULT_READY" && state.stopped === 3) {
return { type: "result", outcome: event.outcome };
}
return state;
case "result":
if (
event.type === "OPEN_FEATURE" &&
state.outcome.feature === "free-spins"
) {
return {
type: "feature",
outcome: state.outcome,
feature: "free-spins",
};
}
return event.type === "RESET" ? initialState : state;
case "feature":
return event.type === "RESET" ? initialState : state;
}
}
Two guards matter most. A second SPIN event is ignored after leaving idle, which blocks duplicate-click races. A RESULT_READY event is rejected until all three reel-stop events have been observed. The visual animation can finish asynchronously without giving asynchronous callbacks permission to skip the state model.
For readers comparing Slot Help Win slot mechanics with implementation concepts, keep the distinction clear: the article is borrowing familiar slot-screen stages as a UI example, not asserting that any particular site uses these state names, events, or architecture.
Derive controls from state
Buttons should reflect the machine rather than maintain separate disabled flags.
export function controls(state: SpinState) {
return {
spinDisabled: state.type !== "idle",
resetDisabled:
state.type !== "result" && state.type !== "feature",
};
}
A component can render disabled={controls(state).spinDisabled} for its Spin button. The important detail is that the disabled state is derived. There is no second source of truth that can drift out of sync after a timeout, animation cancellation, or rapid click.
This also makes reset behavior explicit. Reset is unavailable during spinning and stopping, but becomes legal after a normal result or feature screen. If a product needed cancellation, that should become a named event with a designed transition instead of an emergency boolean.
Keep rendering one-way
The component layer should read the current state and render from it, not secretly advance the machine. Animation-heavy interfaces often mix rendering with progression: a reel finishes, changes a local flag, enables a button, and separately tells a parent that stopping is complete. Those duplicated responsibilities create mismatches.
A cleaner boundary is: effects send events; state decides meaning; rendering reflects state. The reel animation may report REEL_STOPPED, but only transition() decides whether that event changes anything. The button reads controls(state) instead of deciding whether another spin is acceptable.
For example, a framework component can derive a small view model:
function view(state: SpinState) {
return {
showSpinner:
state.type === "spinning" || state.type === "stopping",
showResult:
state.type === "result" || state.type === "feature",
stoppedReels:
state.type === "spinning" || state.type === "stopping"
? state.stopped
: 0,
};
}
This arrangement helps during rerenders. React, Vue, Svelte, or a vanilla DOM layer can recreate visual output without inventing a new machine state. If a component remounts, the authoritative value still describes the phase.
It makes logging easier. Recording each previous state, event, and next state produces a readable transition trace. For a synthetic demo, that trace is better than scattered timer logs because it shows which event was accepted, ignored, or guarded.
That discipline makes debugging easier when animation callbacks finish in an unexpected order during rapid rerenders.
One-way rendering also gives accessibility code the same source of truth. A status message can announce “reels stopping” from the current state, and the Spin button can expose disabled consistently. Visual animation, controls, status text, and tests all observe the same state instead of maintaining parallel interpretations.
Coordinate asynchronous reel stops
A slot spin state machine becomes especially useful when visual events finish at different times. The following runner simulates three delayed reel stops, but it never decides whether a transition is legal. It only sends events.
export async function runSyntheticSpin(
send: (event: Event) => void,
outcome: Outcome,
wait: (ms: number) => Promise<void> = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms)),
) {
send({ type: "SPIN" });
await wait(10);
send({ type: "BEGIN_STOPPING" });
for (let i = 0; i < 3; i += 1) {
await wait(10);
send({ type: "REEL_STOPPED" });
}
send({ type: "RESULT_READY", outcome });
}
In a browser, each wait could instead correspond to animation completion, a Web Animation API promise, or a reel component callback. Keeping those effects outside transition() is useful because the state logic remains deterministic and easy to test.
The synthetic outcome is supplied by the caller. Nothing here generates gambling results.
Branch into an optional feature
The result state owns the outcome, so it can guard feature entry without creating another global flag.
const sampleOutcome: Outcome = {
reels: ["B", "B", "B"],
feature: "free-spins",
};
let state: SpinState = { type: "result", outcome: sampleOutcome };
state = transition(state, { type: "OPEN_FEATURE" });
console.log(state.type); // "feature"
If feature is absent, OPEN_FEATURE is ignored. That gives the UI a clean branch: ordinary synthetic results can reset directly, while feature-tagged results may move through the extra screen first.
This is deliberately presentation logic. A production game would have much broader requirements around result authority, auditing, reconnects, and regulatory controls. Those concerns are outside this front-end demonstration.
Test invalid transitions, not only the happy path
A state machine earns its keep when tests prove that bad timing cannot corrupt it. Node’s built-in test runner is enough for this small example after compiling the TypeScript source.
import test from "node:test";
import assert from "node:assert/strict";
import {
controls,
initialState,
runSyntheticSpin,
transition,
} from "../dist/machine.js";
const plain = { reels: ["A", "K", "Q"] };
const bonus = { reels: ["B", "B", "B"], feature: "free-spins" };
test("duplicate SPIN is ignored", () => {
const spinning = transition(initialState, { type: "SPIN" });
assert.deepEqual(
transition(spinning, { type: "SPIN" }),
spinning,
);
assert.equal(controls(spinning).spinDisabled, true);
});
test("early result is rejected", () => {
let state = transition(initialState, { type: "SPIN" });
state = transition(state, { type: "BEGIN_STOPPING" });
state = transition(state, { type: "REEL_STOPPED" });
assert.equal(
transition(state, {
type: "RESULT_READY",
outcome: plain,
}).type,
"stopping",
);
});
test("feature needs a tagged outcome", () => {
assert.equal(
transition(
{ type: "result", outcome: plain },
{ type: "OPEN_FEATURE" },
).type,
"result",
);
assert.equal(
transition(
{ type: "result", outcome: bonus },
{ type: "OPEN_FEATURE" },
).type,
"feature",
);
});
test("synthetic runner reaches result", async () => {
let state = initialState;
await runSyntheticSpin(
(event) => {
state = transition(state, event);
},
plain,
async () => {},
);
assert.equal(state.type, "result");
});
I also tested the complete sample with six assertions covering duplicate spins, premature results, three reel stops, feature guards, reset behavior, and the asynchronous runner. All six tests pass after TypeScript compilation.
Why this structure scales better
The main benefit is not sophistication; it is ownership. One function owns transition rules. One state value owns UI status. Asynchronous code requests transitions instead of mutating unrelated flags. Tests can deliberately fire events in the wrong order and verify that nothing breaks.
If another state becomes necessary, such as error or reconnecting, add it to the union and define its legal events. TypeScript then exposes switch statements and UI branches that need updating.
For a demonstration with animated reels, that is enough architecture to prevent the most common conflict: the interface saying two incompatible things at once. The result is still synthetic, the feature is only a branch, and the code remains useful as a general front-end coordination pattern rather than a real-money slot implementation.

Top comments (0)