Home › JWorld Blog

One game, many storefronts: how we ship to the web, portals and app stores from one codebase

JWorld team ·

How the JWorld team ships one game to the web, game portals and app stores from the same code. Build modes, platform adapters, ad rules and tests, shown with the real code of Hanpan.

Once a game is finished, there are a lot of places to put it: our own website, game portals, Google Play, the App Store, Toss, ONE store. Each one has its own ad SDK, its own way to save data and its own idea of what the Back key does.

The JWorld team deals with this by pushing every difference out of the game code. This is a dev note on how that works, using the real source of Hanpan, our Janggi and Gomoku game, as the example.

Hanpan's Janggi screen. The top has Janggi and Gomoku tabs plus AI ladder, Quick game, Two players and Puzzles; the bottom has Undo, Pass, Resign and Rules.
Hanpan's Janggi screen. The top has Janggi and Gomoku tabs plus AI ladder, Quick game, Two players and Puzzles; the bottom has Undo, Pass, Resign and Rules.

The game code does not know where it runs

Hanpan is built with Vite and TypeScript. The folders split like this.

FolderWhat it does
src/corePure game logic: Janggi and Gomoku rules, the AI, saved progress. No DOM at all
src/ui and render filesDrawing the board, panels, effects
src/platformOne adapter per channel
src/i18n*The 12 languages of text

The important part is that src/core is pure. With no screen involved, the rules can be checked from a terminal with node --test. When a channel changes, this folder does not.

The channel is picked at build time

Game code never asks which channel it is on. It imports one name, @platform, and the build config (vite.config.ts) decides which file that name points to.

BuildTargetOutput folder
defaultThe web (the games.jworld.dev build adds anonymous play counts)dist-web
--mode portalWeb game portals reached through Playgama Bridgedist-portal
--mode tossApps in Tossdist
--mode onestoreONE store web productdist-onestore
--mode playThe Android app (Capacitor); the iOS build uses the same modedist-play
separate config fileTikTok mini gamedist-tiktok

Changing the mode gives you a bundle from the same source with a different adapter file swapped in. A side benefit: code that only one channel needs never ends up in another channel's bundle.

What an adapter is responsible for

Every adapter fills in the same Platform interface. These are the pieces Hanpan has.

  • Storage: get, getMany, set, remove. Portals want values read in one batch at startup, which is why getMany exists.
  • Rewarded ads: available asks whether an ad can be shown right now, and show shows it.
  • Interstitial ads: on a channel without full-screen ads this is a function that does nothing.
  • Play signals: the first interactive frame (ready) and the start and stop of a game (gameplayStart, gameplayStop). Portal SDKs listen for these.
  • Anonymous stats: only switched on in our own site build. On any other host nothing is sent.

There are exactly seven channel names: web, portal, toss, onestore, twa, play and tiktok. The Google Play TWA route simply opens the web build at ?src=twa, so it does not need a bundle of its own.

Ad rules live in one place

Ads differ the most between channels, which makes them the easiest place to cause an incident. Hanpan writes its interstitial rule as pure functions with no DOM and no ad SDK, and tests them. The rule:

  1. At least 3 counted games so far (a game counts once the player has made 10 or more moves). Never in the first two games.
  2. At least 3 games since the last interstitial, so at most one in three.
  3. At least 180 seconds since the last one.
  4. At least 15 minutes since the first launch.
  5. Not on a result window where the player watched a rewarded ad.
  6. Not on "retry" after losing the same level twice in a row.
  7. Never after a two-player game, on any channel.

Only the portals, the Google Play app and TikTok have full-screen ads at all. On the plain web build, Toss, ONE store and the TWA, nothing is shown even when the rule passes. Rewarded ads are always a bonus, so on a channel where an ad cannot be shown the button is not drawn.

With the rule in one file, adding a channel does not spread "it works like this here and like that there" across the code.

Same game, different Back key

On Android the Back key closes the app. On the web it moves through browser history. Hanpan builds a fake browser history that behaves like Chrome's Back button, then tests the adapter's back behavior against it in back.test.ts. We prefer to catch this kind of channel difference in a test before a person taps through it on a phone.

Hanpan's Gomoku ladder map. A winding road connects characters for levels 5 to 10, and the top right shows 9 / 30 stars.
Hanpan's Gomoku ladder map. A winding road connects characters for levels 5 to 10, and the top right shows 9 / 30 stars.

Twelve languages, outside the adapters

Language is kept apart from channel code too. The list of 12 languages and the rule for choosing one from the browser language live once in apps/shared/i18n, and every game shares them. Hanpan has one file per language, such as i18n-en.ts and i18n-ja.ts; a missing string falls back to English, then Korean. If the host platform reports a language (the adapter's language value), that is followed first.

The languages are Korean, English, Japanese, Simplified and Traditional Chinese, Spanish, Brazilian Portuguese, German, French, Indonesian, Thai and Vietnamese. Store descriptions and screenshots are made in the same 12.

Adding a new channel

  1. Write one adapter file that fills in the Platform interface.
  2. Add one line to the mode table in vite.config.ts with the adapter and the output folder.
  3. Add a build:<channel> script to package.json.
  4. Tell the ad-rule code whether that channel supports interstitials.
  5. Run the rule tests and the channel tests (npm test, npm run typecheck).

Game logic (src/core) appears nowhere in those five steps. That is the point: adding a channel should never break a rules test.

Summary

  • Game logic sits in src/core, with no screen and no SDK, and is checked with node --test.
  • The channel is chosen by swapping the @platform adapter through the build mode.
  • Adapters line up storage, rewarded and interstitial ads, play signals and stats behind one interface.
  • Ad rules are pure functions in one place; channels without full-screen ads show nothing.
  • Languages are managed once, in a shared folder.

You can play Hanpan in your browser, and its page is at /en/apps/hanpan/.

Written by the JWorld team from our game design documents and measured play data, with AI tools helping with the writing.

More posts

180 Fish and No Artist: How Bobber Rise Draws Its Fish From Parts in Code

A dev note on how the JWorld team generates and draws all 180 album fish in Bobber Rise from a handful of code parts, with the real numbers, the distinctness rules and the tests that guard them.

devprocedural artBobber Rise

Gomoku Rules Explained: Free, No 3-3 and Renju

The same five-in-a-row game plays differently under different rules. Learn what each of the three rule sets forbids, how to spot 3-3, 4-4 and overlines, and how to practice with the daily puzzle.

guidegomokuboard games

Hwacha Fortress Beginner Guide: Blocks, Tiers, Boss Timings and Beacon Levels

How to survive your first ten minutes in Hwacha Fortress: steam charge and crushing, which blocks to pick, the four castle tiers, when the three bosses arrive, and what each beacon level changes.

guideHwacha Fortresssurvivor

Korea's Village Guardian Posts and the Five Elements: What Jangseung Guard Borrows and What We Invented

What jangseung posts were in Korean villages, how the five elements (wood, fire, earth, metal, water) work, and how Jangseung Guard turns them into game rules.

jangseungfive elementsKorean folklore