@artubeplate/core
v0.3.4
Published
Batteries-included Artube RGS game core — SignalR transport, an artifact-backed mock RGS, round FSM, the open-slot-ui HUD, free rounds, reconnects, errors and math tooling. Bring a scene, a Present phase and a pure interpretBook; the core does the rest.
Maintainers
Readme
@artubeplate/core
The batteries-included game core for the Artube RGS. A game supplies a config, a pure
interpretBook, a scene and its Present phase. The core does the rest: the Artube loader, the
SignalR connection, session, the HUD, the round loop, free rounds, max win, reconnects, errors,
autoplay, turbo, buy features, lucky bets, audio and gamification.
Built on open-slot-ui (@open-slot-ui/dom ≥ 0.18.2, the DOM HUD),
@microsoft/signalr,
@artube/loader and
@schmooky/zvuk (audio).
The fastest way in is npm create artubeplate@latest, which sets all of this up
(create-artubeplate).
Install
npm i @artubeplate/core @open-slot-ui/core @open-slot-ui/dom @artube/loader @schmooky/zvuk
npm i pixi.js gsap # for @artubeplate/core/scenes, /ui, /fx and /motionNode ≥ 22.15 for the CLI (it uses node:zlib zstd). The browser code has no such requirement.
The one call
import { createArtubeGame } from '@artubeplate/core';
const game = createArtubeGame<MyModel, MyScene, MyBook>({
config: {
title: 'My Slot',
gameServerId: import.meta.env.VITE_GAME_SERVER_ID, // fallback for the hub's `game`; launches carry ?gameId=
modes: { buyfs: { name: 'Free Spins', description: 'Buy 10 free spins.' } },
maxWinX: 5000,
rules: (session) => buildMyRules(session),
fromSession: (session) => ({ /* config derived from session.gameSettings.config */ }),
},
interpretBook: (payload, info) => parseBook(payload, info), // pure: book → your model
mountView: async (host, ctx) => new MyScene(host, ctx), // your pixi scene
phases: [myPresentPhase], // Idle, Spin and Settle are provided
audio: { sounds: [{ name: 'bgm', kind: 'music', loop: bgmUrl }], bgm: 'bgm' }, // import bgmUrl from './bgm.webm' (hashed)
hudHost: document.getElementById('hud')!,
sceneHost: document.getElementById('scene')!,
});
await game.start();What start() does
- Shows the Artube loader and drives its progress.
- Connects: the real hub when the launch URL carries a
sessionId, otherwise the mock RGS on yourmath/folder. - Calls
sessionand builds the HUD from it: currency (demo =null), the bet ladder fromallowedBets, one card perBuyFeature/LuckyBetmode with its price, RTPs for the rules. - Calls your
mountView. Its return value isctx.viewin the phases. - Goes idle, then offers a round the player did not see (after a reload) and the operator's free-rounds campaign.
After that the core owns the platform rules for the whole session. The full mapping is in docs/ARTUBE_RGS.md.
The round
Idle → Spin → Present → Settle. The core ships Idle, Spin and Settle:
- Spin sends
spin { gameMode, betIndex }(betIndex: nullin free rounds), debits the price optimistically, and calls your view'sonSpinStart()so the reels move before the answer. On a refused spin it restores the balance and shows the error. On an unknown outcome it never retries: it reconnects and replaces state fromsession. - Present is yours.
ctx.roundholds the platform's money (bet,stake,totalWin,multiplier,maxWinReached,freeRound) and your model inctx.round.data. Usectx.turbo.delay(ms)for animation timing: turbo shortens it and a slam-stop resolves it.ctx.turbo.beat(ms, floorMs)is the same delay with a floor of real time, for what must be read at any pace: a word on screen, a summed win box. - Settle lands the authoritative balance and win, reports the round to the HUD (autoplay
limits, net position), enforces
minRoundMs, and shows the blocking max-win modal when the platform capped the win. There is no in-game HISTORY: Artube keeps the rounds on the platform.
A view may implement ViewHooks: onSpinStart(), onSpinAbort(), slamStop(), and
whenPlayable(). With whenPlayable(), the core keeps the HUD transparent, inert and
click-through, and holds the first idle and the free-rounds offer until it resolves; an intro
screen uses this.
GameConfig
| Field | |
|---|---|
| title, version | |
| gameServerId | A fallback for the hub's game parameter. Artube launch URLs carry it as ?gameId= (the publicGameId; <publicGameId>-stage on Stage), which wins. |
| modes | Card copy per mode id: name, description, image, volatility, confirm. |
| rules | The INFO window: an open-slot-ui MenuSpec, blocks, or (session, kit) => …. The RulesKit gives kit.price(modeId) (what one round of a mode costs, e.g. "2×": a boost's full price, not the surcharge {{cost.<id>}} names) and kit.money(x) (x × the default bet in the session's currency, for worked examples). |
| maxWinX | The math's cap as × bet, for the HUD and rules. |
| freeSpins | { count, retrigger } or false, for the rules audit. |
| autoplay | { lossLimits, winLimits, requireLimits, stopOnAnyWin } in × bet. |
| turboSpeeds, autoplayGapMs, minRoundMs | Feel and compliance timing. |
| bonusSkip | Let the player skip inside a bonus (Space, the round button's STOP, a tap on the game). Default false: a bonus has no round button and plays out at its own pace. |
| featureCounter | The counter beside TOTAL WIN in a feature: { label: 'Respins', lives: 3 } for a Hold & Win shows RESPINS with three pips, lit up to hud.setFreeSpins(n). Default: FREE SPINS and a number. |
| jurisdiction, hud, spec, messages | Passed to open-slot-ui. |
| fromSession(session, kit) | Derive any of the above from the session (for example from the math's settings.config). Merged over the static config; kit is the same RulesKit. |
What the core takes care of for Artube's TRC checklist
Every game on the core gets these without a line of its own:
- Launch.
sessionId,gameId(the hub'sgame;<publicGameId>-stageon Stage) andlngare read from the URL. A deployed build never falls back to the mock RGS: opened without a session it says "No game session". The mock is not even in that build:createArtubeGameloads it with a dynamic import that onlyvite devand demo builds keep, so MockRgs, the math loader and fzstd stay out of the bundle you upload (tests/treeshake.test.ts checks it). - Money rules. The stake is sent as a bet index. An unaffordable spin is blocked before any request, with "Insufficient funds". Autoplay stops on a low balance and says why. A refused spin refunds its optimistic debit.
- Errors. A server 500 or a lost answer never retries: the session is re-read, and a 500 is reported to the player. A closed session or a second connection is terminal.
- State recovery. After a reconnect or a reload, the session is the truth. A round the player did not see to the end (Artube's
session.round.spinResults) is offered as "Your last round… Watch it now?". It is offered once per device and per game, and never while free rounds play. - Free rounds.
- Every new session opens with the offer, before any "Your last round". It shows the count, the bet and the expiry (read as UTC).
- A campaign started in an earlier session is offered again with the rounds left and the win so far.
- After "Play now", the rounds play by themselves at the campaign's bet with the bet locked. Auto-play waits for an open notice to close instead of stopping.
- While they play, TOTAL WIN | FREE ROUNDS stand in the round button's place and the stake shows among the readouts (the HUD's
hud.freeRounds: 'takeover', on by default; a game may set'inline'). - The player's choice is kept per session. A reload after accepting shows the rounds left with one "Continue" and carries on. "Later" is not asked again in the same session.
- A reconnect re-activates the campaign silently (Artube's activation is per connection).
- The summary shows the total.
- HUD. Most of these are open-slot-ui's own (≥ 0.18.2); the core turns them on and feeds them.
- In a demo session (
currency: null), "DEMO" stands where the currency would: 1.00 DEMO in the balance, win and bet. - SPACEBAR TO SPIN in the ☰ menu (desktop only; the HUD's
spacebarrow andui.spacebar). - Sound, music, the mute, TURBO / SUPER TURBO and spacebar survive a reload, per game (
mountDomHud(spec, { persist })). - Turbo switched mid-round counts from the next round.
- During a round, autoplay or free rounds the bet, the buy coin and the autoplay button are disabled, while SPIN (slam-stop) and STOP stay live.
- In a demo session (
- Phones. No pinch-zoom (iOS ignores
user-scalable=no): the HUD refuses it, and the core the page. Panning still works.
What stays the game's: the paytable and rules content (RulesKit helps), thumbnails, and its own art and sound.
The game handle
game.inspect(); // { phase, balance, bet, betIndex, lastWin, currency, spinning, freeRounds, … }
game.requestSpin(); // spin as the round button does (idle only)
game.requestBuy('buyfs');
game.network; // the transport; the MockRgs instance in local dev
game.hud; // the mounted open-slot-ui DomHud
game.dispose();Styling
The HUD is open-slot-ui's DOM HUD in one built-in look, the classic slot bar: the dark plate
with BALANCE / WIN, the boxed BET / SPIN / autoplay panel and the BUY BONUS coin (open-slot-ui's buy colour unless the game's theme sets featureBuy) on
desktop; SPIN between − and + over a readout strip on phones; white bonus cards, the black ☰
menu, and TOTAL WIN | FREE SPINS during free spins. It mounts automatically and there is nothing
to pick. The stylesheet (artubeplateSkinCss) and its SVG glyphs are drawn in this package, so
the HUD ships no third-party CSS, fonts or images. hudOptions passes through to mountDomHud.
Subpaths
| Import | Contents |
|---|---|
| @artubeplate/core | createArtubeGame, the engine (FSM, phases, turbo, beats), stores, currency, the skin CSS, and everything in /rgs. |
| @artubeplate/core/rgs | Wire types and tolerant normalizers, RGS_ERRORS / RgsError, readRuntime / isArtubeLaunch / hubUrl, ArtubeRgsClient, MockRgs, loadNetwork (the transport for this launch; the mock only in dev and demo builds) and createNetwork (its synchronous form, which ships the mock in every build). |
| @artubeplate/core/math | The artifact rules as code: validateArtifact (isomorphic, streaming), parseManifest, exact RTP, the zstd one-frame check, loadArtifact for the mock, inferSlotConfig. |
| @artubeplate/core/rules | The artubeplate/slot@1 schema (parseSlotConfig), buildSlotRules (paytable, specials, paylines, per-mode sections, RTP grid, controls, legal), buildIntroSlides (intro cards from the config and the modes), symbolTile. |
| @artubeplate/core/scenes | Scenes and transitions for pixi v8 + GSAP: SceneDirector (persistent backdrop and foreground layers), SceneContainer (a scene that is a pixi Container, laid out in a design frame), SmartContainer (per-orientation layout data in design units, pinChildAt, a SmartViewport such as hudPlayArea(host), the screen above the HUD bar that follows the bar by itself; measureHudPlayArea measures it once), slamReels (a slam-stop that waits for a reel still pushing off, so it never jumps), transitions cut, dim, shader (dissolve, wipe, iris, burst, halftone, peel, pixelate, fade, or your own GLSL) and morph (keep the background, swap objects on GSAP timelines), the timeline helpers popIn / dropIn / fadeIn / fadeOut / scaleIn, IntroScene, and stickerSlap (a snapshot of any container presses onto the scene like a bowed sticker; StickerQuad is the sheet with movable corners and a bow). See docs/SCENES.md. |
| @artubeplate/core/ui | In-canvas UI that looks finished out of the box: configureTypography + text roles (label, textStyle: white, outlined, readable), ensureFonts, WinBox (count-up), WinAnnouncer (BIG → MEGA → EPIC WIN, counted up, text or art titles, FX hooks), lineWinLabel, BigWinBanner, drawPanel, drawBackdrop. See docs/SCENES.md. |
| @artubeplate/core/fx | Cartoon effects: Blitter (sprites burst out of a point and streak into a counter, calling back on every arrival) and CartoonFx (GPU particle bursts on pylinka: sparkle, shard, puff, confetti, glow; pooled emitters, presets as data, your art or the drawn defaults). CartoonFx.create() loads @pylinka/core and @pylinka/graph when called, so they are needed only by games that use it. StickerBakery bakes the sticker look (an ink line hugging the shape, a white border around it, measured against the piece) onto flat art at load, so every piece and anything drawn in code share one line weight. LineShow draws a line slot's win lines, white and simple: the round's overview (every line at once, every winning symbol woken) and the idle cycle's snake (a thick head tapering to a thin tail racing the zigzag into the last symbol, with an optional thin trail), every fade a group fade through one AlphaFilter, so crossing lines and joins never show while they fade; SilhouetteMask keeps those lines behind the winning symbols, holes included (a Q's counter), by masking the line layer with the symbols' hole-filled silhouettes re-posed every frame; silhouetteOf makes such a silhouette for any texture. See docs/SCENES.md. |
| @artubeplate/core/motion | The studio motion style as data and code (pixi v8 + GSAP 3): motion.rig wraps a piece in a root for layout and move / body layers for motion; motion.play(rig, name, { variant, dir, speed, on }) plays a primitive from the spec (appear, popIn, land, slam, exit, wow S/M/L, pulse, float, tease, shake, hop, roll, tumble, turn, swap, flip); motion.fly / motion.count / motion.cascade / motion.prism for groups, counters and cubes; motion.skip / motion.stop; motion.settled(animation) (a promise that also settles when the animation is cut short, for cleanup after it); motion.seconds / motion.ease for the tokens; and motion.spec, the playbook's motion-style.json. See Motion. |
| @artubeplate/core/audio | GameAudio / createGameAudio: the slot mix on zvuk (below). Loaded lazily by the core when a game passes audio. |
| @artubeplate/core/assets | The game side of the asset pipeline: loadAssets (the manifest's texture bundles through Pixi's Assets, with progress), soundsFromManifest (its audio as zvuk entries: folder = bus, .N = variants, music stems), textureManifest. |
| @artubeplate/core/testing | MockRgs, InstantTicker and mockHud(), a recording HUD port, to run whole rounds headless. |
| @artubeplate/core/vite | artubeplate(): serves math/ to the mock during vite dev and validates it on start; proxies /api-rgs to $ARTUBE_HOST; runs the asset pipeline over raw-assets/ (AssetPack: spritesheets, @0.5x, png/webp, webm + mp3, content-hashed names) and serves its manifest as import assets from 'artubeplate:assets'. @assetpack/core is needed only when the folder exists. |
| @artubeplate/core/node | validateDir, loadArtifactDir, writeArtifact, convertStake, packDir, ensureConfig: the Node half of the math tooling. |
Sound
Pass audio: { sounds, bgm } to createArtubeGame and the core builds the mix on
zvuk:
master (headroom −3 dB → limiter at −1 dBFS)
├── MUSIC group (the HUD's MUSIC row) → music · ambience each: compressor → ducker
└── EFFECTS group (the SOUND row) → reels · symbols · anticipation · wins · voiceover · ui · reverb
each: compressor, 8-voice cap- A compressor tuned per bus (
BUS_COMPRESSION: gentle on music, punchy on reels and wins) and a limiter on the master, out of the box.compression: { wins: { ratio: 6 }, ui: false }tunes a bus,compression: false/limiter: falseturn them off. - The music ducks under
wins(duckMusicFrom,duckAmount); every one-shot is loudness normalized on load; effects buses cap at 8 voices; everything is suspended while the tab is hidden (pauseWhenHidden). - Sounds: one file or a codec ladder (
['x.webm', 'x.mp3']),kind: 'variants'(a different take on every play, shuffle-bag by default), orkind: 'music'(intro → loop → outro, or a plain loop with a seamless crossfade). audio.play(name, { volume, pitch })(jitter with{ base, jitter }; an unknown name warns once and never throws),audio.loop(name)→{ stop },audio.music(name)(crossfades from the playing track),audio.stopMusic({ outro: true }),audio.ambience(name)(a bed under the music, never crossfaded).- The HUD's SOUND / MUSIC rows and mute drive the groups (the HUD saves them);
inputSoundsclicks the HUD's buttons (betUp/betDowntell the bet's direction,skipsounds once for every slam, by the round button, the spacebar or a tap);bgmand theambiencebeds start on the first tap.
With the asset pipeline, soundsFromManifest(assets) writes sounds for you from
raw-assets/audio{m}/<bus>/….
Motion
@artubeplate/core/motion plays the Lowres Labs studio motion on pixi v8 + GSAP 3. The numbers
come from one JSON (motion.spec, the playbook's motion-style.json): a frame scale (5, 8, 13, 21,
34, 55, 89 frames at 60 fps), six eases by intent (out, in, glide, pop, wobble, linear) and every
primitive as steps: appear, popIn, land, slam, exit, wow (S, M, L), pulse, float, tease, shake, hop,
roll, tumble, turn, swap, flip, fly, count, cascade. motion.rig(sprite) wraps a piece in three
layers: root (layout's: pin it), move (x, y, angle, alpha) and body (scale, squash). Steps
marked scaled follow the turbo clock; contacts and accents keep their frames. Every play can
skip() to its end pose, and the contacts call back on their frame (impact, pop, swap,
advance, arrive) for sounds. motion.seconds('quick') and motion.ease('pop') hand the tokens
to a tween of your own.
import { motion } from '@artubeplate/core/motion';
motion.speed = () => ctx.turbo.speed; // 1, 0.4 or 0.12
const coin = motion.rig(Sprite.from('coin-cyan'));
scene.pinChildAt(coin.root, PINS.coin); // layout owns root
await motion.play(coin, 'land', { on: { impact: () => sfx.play('coin-land') } });
motion.play(coin, 'wow', { variant: 'M' });Real RGS or mock
| Launch | Transport |
|---|---|
| ?sessionId=…&gameId=…&lng=… (an operator launch) | The real hub at /api-rgs/api/slot?sessionId=…&game=<gameId>, relative to the page; lng picks the language. |
| No sessionId, or ?mock=1 | MockRgs, drawing from the math the Vite plugin serves. |
The mock takes URL knobs (?balance=, ?currency=demo, ?frc=10, ?maxWin=, ?seed=,
?latency=, ?mockError=503, ?force=buyfs:12) and a scripting API (forceRow, failNext,
simulateReconnect, closeSession, replaceConnection, rowsWhere…). See
docs/ARTUBE_RGS.md.
// vite.config.ts
import { defineConfig } from 'vite';
import { artubePartnerLoader } from '@artube/loader';
import { artubeplate } from '@artubeplate/core/vite';
export default defineConfig(({ command }) => ({
base: command === 'build' ? './' : '/',
plugins: [artubePartnerLoader(), artubeplate()],
}));artubeplate({ mathDir, validate, demoBuild, rgsHost }): mathDir defaults to $ARTUBEPLATE_MATH, then
math. rgsHost (default $ARTUBE_HOST) proxies /api-rgs, HTTP and WebSocket, to a real Artube
environment during vite dev, so http://localhost:8080/?sessionId=…&gameId=… (Artube Hub's Play on Localhost) plays a real session. demoBuild: true copies the math into the build for a mock-only demo site. Never upload
that build to Artube. The plugin also decides whether the mock may run: in vite dev and a demo
build it may; a plain build never runs it, so a deployed game opened without a session shows
"No game session" instead of looking for math.
The artubeplate CLI
artubeplate math validate <dir> [--json] [--no-books-check] # every Hub rule; exit 1 on failure
artubeplate math stats <dir> # the same report, never fails
artubeplate math from-stake <publish_files> --out <dir> [--type m=Purchase] [--volatility High]
# [--config f.json] [--equalize flatten|replicate]
artubeplate math pack <dir> --out <dir> # compress and re-frame a hand-made set
artubeplate math config <dir> [--force] [--title <name>] # infer a starter settings.configThe report shows, per mode: price, declared vs computed RTP (exact), rows, distinct multipliers, hit rate, max win and its odds, and the tail. The rules are listed in docs/MATH_CONTRACT.md.
Testing a game headless
import { MockRgs, InstantTicker, mockHud } from '@artubeplate/core/testing';
import { loadArtifactDir } from '@artubeplate/core/node';
const rgs = new MockRgs({ math: await loadArtifactDir('math'), seed: 1, latency: 0 });
await rgs.connect();
const session = await rgs.session();
rgs.forceRow('base', 12); // the next base round lands on book row 12
const round = await rgs.spin({ gameMode: 'base', betIndex: session.sessionSettings.defaultBetIndex });
rgs.failNext(503); // the spin after that is refused with errorId 503mockHud() records every HUD call and answers notices from a queue, and InstantTicker makes
waits free, so a test can drive the FSM through complete rounds without a browser (the core's own
tests/engine.test.ts does exactly this).
License
MIT © Dmitry Vasiliev
