openvibers/openvibe.games Commits
Remix a creator world into your own, credited; the platform direction written down (#159)
Changes
@@@ -1,6 +1,10 @@export {}--/** /worlds: the published creator worlds, and the signed-in player's own (make, publish, edit, delete). */++/**++ * /worlds: the published creator worlds, and the signed-in player's own (make, publish, edit, delete, remix). A remix++ * copies a published world into a new draft of yours, credited (owner, 2026-10-27: "the same way you can copy our++ * game"); /worlds?remix=<id> (a world page's Remix link) makes one and opens it in the editor, after sign-in if needed.++ */interface World {id: string@@@ -14,6 +18,8 @@ interface World {play_url: stringstatus?: 'draft' | 'published' | 'hidden'edit_url?: string++ remixed_from?: { id: string; title: string; owner: string; url: string } | null++ remixes?: number}interface Mine {@@@ -114,19 +120,58 @@ const count = (n: number, one: string, many: string): string =>function stats(world: World): string {const parts = [count(world.plays, 'visit', 'visits')]if (world.online > 0) parts.unshift(`${world.online} playing now`)++ if (world.remixes) parts.push(count(world.remixes, 'remix', 'remixes'))return parts.join(' · ')}++/** "Remix of <title> by <owner>", the credit a remix carries (null for an original). */++function credit(world: World): HTMLElement | null {++ const from = world.remixed_from++ if (!from) return null++ return el(++ 'p',++ { className: 'by' },++ document.createTextNode('Remix of '),++ link(from.title || 'a world', from.url, false, 'inline'),++ document.createTextNode(` by ${from.owner}`),++ )++}++++const remixStatus = (): HTMLElement => (signedIn.hidden ? publishedStatus : createStatus)++++/** Copy a world into a new draft of yours and open it in the editor. */++async function remixWorld(id: string, b?: HTMLButtonElement): Promise<void> {++ if (b) b.disabled = true++ remixStatus().textContent = 'Making your remix…'++ try {++ const world = await api<World>(`/api/worlds/${encodeURIComponent(id)}/remix`, {++ method: 'POST',++ })++ remixStatus().textContent = ''++ if (world.edit_url) location.href = world.edit_url++ } catch (err) {++ remixStatus().textContent = err instanceof Error ? err.message : 'That did not work.'++ if (b) b.disabled = false++ }++}++function publishedCard(world: World, staff: boolean): HTMLElement {const card = el('article',{ className: 'card' },titleOf(world),el('p', { className: 'by', text: `by ${world.owner}` }),++ credit(world),world.summary ? el('p', { className: 'summary', text: world.summary }) : null,el('p', { className: 'stats', text: stats(world) }),)const actions = el('div', { className: 'actions' }, link('▶ Play', world.play_url, true))++ // Anyone signed in may copy a published world into a world of their own; signed out, the link signs in first.++ if (token()) actions.append(button('Remix', (b) => void remixWorld(world.id, b)))++ else++ actions.append(++ link('Remix', `/auth/login?next=${encodeURIComponent(`/worlds?remix=${world.id}`)}`),++ )if (staff)actions.append(button(@@@ -147,6 +192,7 @@ function mineCard(world: World): HTMLElement {{ className: 'card' },el('span', { className: `badge ${status}`, text: label }),titleOf(world),++ credit(world),world.summary ? el('p', { className: 'summary', text: world.summary }) : null,el('p', { className: 'stats', text: stats(world) }),)@@@ -288,4 +334,21 @@ moreButton.addEventListener('click', () => {void loadPublished()})--void loadMine().then(() => loadPublished(true))++void loadMine().then(() => {++ // A world page's Remix link: make the copy and open it, or sign in first and come back here to do it.++ const want = new URLSearchParams(location.search).get('remix')++ if (want && /^w[a-z0-9]{10}$/.test(want)) {++ if (!signedIn.hidden) {++ history.replaceState(null, '', '/worlds')++ void remixWorld(want)++ } else {++ const signIn = signedOut.querySelector<HTMLAnchorElement>('a.button')++ if (signIn) signIn.href = `/auth/login?next=${encodeURIComponent(`/worlds?remix=${want}`)}`++ const note = signedOut.querySelector('.status')++ if (note)++ note.textContent =++ 'Sign in with your OpenVibe account to remix that world into one of your own.'++ }++ }++ return loadPublished(true)++})@@@ -56,6 +56,8 @@ const world = (id: string, plays: number, publishedAt: number): CreatorWorld =>createdAt: publishedAt,updatedAt: publishedAt,publishedAt,++ remixedFrom: null,++ remixes: 0,})describe('the home page live sections', () => {@@@ -128,6 +128,8 @@ describe('creator worlds in OpenVibe.Search', () => {createdAt: 1,updatedAt: 2,publishedAt: 2,++ remixedFrom: null,++ remixes: 0,},'https://openvibe.games',shell,@@@ -138,5 +140,30 @@ describe('creator worlds in OpenVibe.Search', () => {expect(html).toContain('href="/play?world=wk3j9x0a2bq"')expect(html).toContain('<link rel="canonical" href="https://openvibe.games/worlds/wk3j9x0a2bq"')expect(html).toContain('1 visit')++ expect(html).toContain('href="/worlds?remix=wk3j9x0a2bq"')++ expect(html).not.toContain('Remix of')++ const remix = worldPageHtml(++ {++ id: 'wremix00001',++ ownerSubject: KIM,++ ownerName: 'lee',++ title: 'Moon (remix)',++ summary: '',++ status: 'published',++ revision: 'r',++ plays: 0,++ createdAt: 1,++ updatedAt: 2,++ publishedAt: 2,++ remixedFrom: { id: 'wk3j9x0a2bq', title: 'Moon <script>', owner: 'kim <b>' },++ remixes: 2,++ },++ 'https://openvibe.games',++ shell,++ )++ expect(remix).toContain(++ 'Remix of <a href="/worlds/wk3j9x0a2bq">Moon <script></a> by kim <b>',++ )++ expect(remix).toContain('0 visits · 2 remixes')})})@@@ -38,7 +38,11 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shellconst description =world.summary ||`A creator world by ${world.ownerName} on OpenVibe.Games. Join it from your browser.`-- const visits = `${world.plays.toLocaleString('en-US')} ${world.plays === 1 ? 'visit' : 'visits'}`++ const visits = `${world.plays.toLocaleString('en-US')} ${world.plays === 1 ? 'visit' : 'visits'}${++ world.remixes++ ? ` · ${world.remixes.toLocaleString('en-US')} ${world.remixes === 1 ? 'remix' : 'remixes'}`++ : ''++ }`const links = [{ label: 'Creator worlds', href: '/worlds' },{ label: 'Play Scraplandia', href: '/play' },@@@ -73,14 +77,17 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell<p class="eyebrow">CREATOR WORLD</p><h1>${escapeHtml(world.title)}</h1><p class="by">by ${escapeHtml(world.ownerName)}</p>++ ${world.remixedFrom ? `<p class="by">Remix of <a href="/worlds/${escapeHtml(world.remixedFrom.id)}">${escapeHtml(world.remixedFrom.title)}</a> by ${escapeHtml(world.remixedFrom.owner)}</p>` : ''}${world.summary ? `<p class="summary">${escapeHtml(world.summary)}</p>` : ''}<p class="stats">${escapeHtml(visits)}</p><div class="row"><a class="play" href="/play?world=${world.id}">▶ Join this world</a>++ <a class="more" href="/worlds?remix=${world.id}">Remix it</a><a class="more" href="/worlds">More worlds</a></div><p class="note">Plays in your browser with no download, as a guest or with your OpenVibe account. A world is a-- sandbox: what you gather or build inside it stays in it.</p>++ sandbox: what you gather or build inside it stays in it. Remix it to start a world of your own from this one; the++ remix credits it.</p></main>`,})}@@@ -124,6 +124,56 @@ describe('/api/worlds', () => {expect(parseIfMatch('')).toBeNull()})++ it("remixes a published world into a new draft of the remixer's own, credited and counted", async () => {++ const { call } = await setup(2)++ const made = await call('POST', '/api/worlds', 'kim', {++ title: 'Moon base',++ summary: 'Low gravity vibes',++ })++ const id = String(made.body.id)++ const map = await call('GET', `/api/worlds/${id}/map`, 'kim')++ const next: MapFileV2 = { ...emptyMapV2(), spawn: [7, 1, 7] }++ await call('PUT', `/api/worlds/${id}/map`, 'kim', next, { 'if-match': map.etag ?? '' })++++ // Signed in only, and never someone else's draft.++ expect((await call('POST', `/api/worlds/${id}/remix`)).status).toBe(401)++ expect((await call('POST', `/api/worlds/${id}/remix`, 'guest')).status).toBe(401)++ expect((await call('POST', `/api/worlds/${id}/remix`, 'lee')).status).toBe(404)++ expect((await call('POST', '/api/worlds/wnothere0000/remix', 'lee')).status).toBe(404)++++ await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })++ const remix = await call('POST', `/api/worlds/${id}/remix`, 'lee')++ expect(remix.status).toBe(201)++ const rid = String(remix.body.id)++ expect(rid).not.toBe(id)++ expect(remix.body).toMatchObject({++ title: 'Moon base (remix)',++ summary: 'Low gravity vibes',++ status: 'draft',++ edit_url: `/editor?world=${rid}`,++ remixed_from: { id, title: 'Moon base', owner: 'kim', url: `/worlds/${id}` },++ remixes: 0,++ })++ // The copy is the remixer's: the same map, editable by them, not by the original's owner.++ const copy = await call('GET', `/api/worlds/${rid}/map`, 'lee')++ expect(copy.status).toBe(200)++ expect((copy.body as unknown as MapFileV2).spawn).toEqual([7, 1, 7])++ expect((await call('GET', `/api/worlds/${rid}/map`, 'kim')).status).toBe(404)++ // The original counts it, publicly.++ const listed = (await call('GET', '/api/worlds')).body.worlds ?? []++ expect(listed.find((w) => w.id === id)).toMatchObject({ remixes: 1, remixed_from: null })++++ // Remixing your own world makes a copy without counting; the per-person limit still holds.++ const own = await call('POST', `/api/worlds/${id}/remix`, 'kim')++ expect(own.status).toBe(201)++ expect(++ ((await call('GET', '/api/worlds')).body.worlds ?? []).find((w) => w.id === id),++ ).toMatchObject({ remixes: 1 })++ const full = await call('POST', `/api/worlds/${id}/remix`, 'kim')++ expect(full.status).toBe(409)++ expect(full.body.error).toBe('world_limit')++ })++it('lets a signed-in person make, save, publish and delete a world; staff take one down', async () => {const { call, calls } = await setup()expect((await call('POST', '/api/worlds', undefined, { title: 'x' })).status).toBe(401)@@@ -124,6 +124,16 @@ function publicView(world: CreatorWorld, online: number) {published_at: world.publishedAt === null ? null : new Date(world.publishedAt).toISOString(),updated_at: new Date(world.updatedAt).toISOString(),play_url: `/play?world=${world.id}`,++ // A remix credits the world it copied (as it was then); remixes counts how often others copied this one.++ remixed_from: world.remixedFrom++ ? {++ id: world.remixedFrom.id,++ title: world.remixedFrom.title,++ owner: world.remixedFrom.owner,++ url: `/worlds/${world.remixedFrom.id}`,++ }++ : null,++ remixes: world.remixes,}}@@@ -303,6 +313,38 @@ export function createWorldRoutes(opts: WorldRouteOptions) {return}++ // POST /api/worlds/:id/remix: a published world (or one of your own) copied into a new draft of yours, credited++ // (owner, 2026-10-27: "other people can make games just like the same way you can copy our game"). The copy is++ // yours to change and publish; the original is untouched and counts the remix.++ const remix = /^\/api\/worlds\/([^/]+)\/remix$/.exec(path)++ if (remix && method === 'POST') {++ const user = await signedIn(req)++ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return++ const source = await opts.store.get(remix[1] ?? '')++ const own = !!source && source.ownerSubject === user.subjectId++ if (!source || (source.status !== 'published' && !own)) fail(404, 'not_found')++ if ((await opts.store.countByOwner(user.subjectId)) >= opts.perPerson)++ fail(409, 'world_limit', {++ detail: `You can keep ${opts.perPerson} worlds. Delete one to make another.`,++ })++ const stored = await opts.store.map((source as CreatorWorld).id)++ if (!stored) fail(404, 'not_found')++ const src = source as CreatorWorld++ const world = await opts.store.create({++ ownerSubject: user.subjectId,++ ownerName: user.name,++ title: `${src.title.slice(0, TITLE_MAX - 8).trimEnd()} (remix)`,++ summary: src.summary,++ map: (stored as { map: unknown }).map,++ now: now(),++ remixedFrom: { id: src.id, title: src.title, owner: src.ownerName },++ })++ if (!own) await opts.store.countRemix(src.id)++ opts.log.info('creator world remixed', { world: world.id, from: src.id })++ send(res, 201, ownerView(world, 0))++ return++ }++const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)if (!match) fail(404, 'not_found')const id = match?.[1] ?? ''@@@ -18,6 +18,7 @@ Scraplandia's world is **Scrap Island**, a map document like any the editor saveThe generator is `apps/server/src/maps/scrapIsland.ts`. It is deterministic, and it refuses to overwrite an existingfile without `--force`, because that file may hold staff edits.++- **Terrain**: one 420 m heightfield (2 m cells) above the sea at `WATER_LEVEL`, painted grass, rock (slopes, heights)and sand (shores) over a dirt base. The dirt shows on the roads.- **Scrap City** (the safe zone `scrap-city`, walls at ±38.5): the plaza and fountain, four market stalls, a workbench,@@@ -87,6 +88,12 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in- **Sandbox:** characters are read but never saved in a creator world (`worlds/sandboxStore.ts`), and the room hasno NPCs, world events, rewards, boards or events. Nothing gathered, crafted, traded or dropped in somebody's worldreaches Scraplandia, the leaderboards or the Inventory. The world's own rows (props, constraints, clock) persist.++- **Remix:** `POST /api/worlds/:id/remix` copies a published world (or one of your own) into a new draft of yours,++ titled "… (remix)", within your world limit. It is yours to change and publish. The copy keeps the credit++ (`remixed_from`, with the title and owner as they were when copied; migration 0013), shown on its page and cards,++ and the original counts `remixes` from other people. A world page's **Remix it** link goes through++ `/worlds?remix=<id>`, which signs you in first if needed and opens the copy in the editor (owner, 2026-10-27: "other++ people can make games just like the same way you can copy our game").- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the ownercannot publish it again. Deleting a world removes its place's rows. A world's first publication is the eventnnew file mode 100644@@@ -0,0 +1,82 @@++# OpenVibe.Games as an open game platform++++The owner, 2026-10-27:++++- "we want to make it more modular open to modder it's more like Gary's mod or the new s&box game or Roblox … make more of a game engine both 3D and 2D … monetizable using openvibe systems";++- instant-play browser games that "x.com or Twitter ads" link to, with cheap coin packs (the g123.jp model: no download, play on the site);++- "other people can make games just like the same way you can copy our game … make teams … asset store".++++## What the market does (researched 2026-10-27)++++**s&box (Facepunch, Garry's Mod's successor)**++++- Launched on Steam on 2026-04-28. It runs on Source 2, has C# scripting, an editor and built-in multiplayer, and is MIT-licensed.++- Facepunch had paid creators $500k by launch. A March 2026 Valve agreement lets editor projects ship as royalty-free standalone Steam games.++- Reported since then: a move to free-to-play by the end of 2026, after paid access and cosmetics put players off; weak retention; open books showing a loss.++- Lessons: openness and creator pay draw creators, but a paid door and a thin player base hurt.++++**Roblox**++++- Creators earn through engagement payouts (Creator Rewards, from the play time of Premium and ad-eligible users) as well as their own sales. Robux cash out through DevEx.++- Payout rates rose 8.5% in September 2025. Third-party estimates put creator payouts at roughly 31% of revenue (unaudited).++- Lesson: pay creators for engagement, not only for sales, so free games are worth building.++++**Poki and CrazyGames (instant web play)**++++- HTML5 games, curated, ad-funded.++- Reported splits: Poki 100% to the developer for players they bring and 50/50 for players Poki brings. CrazyGames is cited at 60% of ad revenue and 70% of purchases (jam terms).++- Poki asks for a first download under about 8 MB.++- Lesson: a small first load and no install are the price of entry.++++**G123 (CTW, Japan)**++++- HTML5 anime games with no download, no install and no required sign-up. It reports over 500M users.++- It is known for heavy social-media ads, and a UK ASA investigation in 2025 found some unsuitable. The Nasdaq stock is down about half since its IPO.++- Lesson: the funnel works, but its ads damage the brand. Our rules already forbid what drew the complaints: nothing sexual with minor-looking characters, original characters only.++++Sources:++++- [GamingOnLinux, s&box launch](https://www.gamingonlinux.com/2026/04/facepunch-launches-s-box-the-highly-anticipated-successor-to-garrys-mod/page=1/)++- [games.gg, s&box creator payouts](https://games.gg/nl/news/sbox-lanceert-op-steam-met-duurzame-uitbetalingen-aan-makers-en-kritiek-op-epic/)++- [VGTimes, s&box going free](https://vgtimes.ru/gaming-news/171063-sbox-stanet-besplatnoy-naslednica-garrys-mod-otkazyvaetsya-ot-platnogo-vhoda.html)++- [Nasdaq, Roblox payouts](https://www.nasdaq.com/articles/roblox-raises-creator-payouts-will-margins-face-pressure-2026)++- [Cinevva, publishing on Poki](https://app.cinevva.com/guides/publish-game-poki)++- [Cinevva, web game monetization](https://app.cinevva.com/guides/web-game-monetization)++- [Wikipedia, CTW Inc.](https://en.wikipedia.org/wiki/CTW_Inc.)++++## Where Games stands++++Already here:++++- **Content packs and mods:** content packs (`games-content@2`, declarative mods with per-capability grants) and server script mods (`games-quickjs@1`, with capabilities and CPU, memory and stack budgets); see `mods.md`.++- **Creator worlds:**++ - an editor, publishing, its own pages, Search and Quest events;++ - **remix** (copy a world into your own, credited);++ - characters that belong to the game (no platform slot cap, since 2026-10-27).++- **The casual kit (`casual.md`):** small DOM games with no Babylon and about 26 KB gzip, guest play from a link, campaign numbers, and earned-only summons.++++## The plan, in order++++1. **Characters belong to the game.** Done (Games#158): no "3 slots" door, a 24 technical ceiling. Next, a game's pack may declare its own character rule, read by the shell.++2. **Remix.** Done: copy, credit, count.++3. **Worlds with scripts.** The Garry's Mod moment.++ - A creator world may carry `games-quickjs@1` mods, with the same capability grants and budgets as packs, sandboxed to that world.++ - Its owner can edit them from the editor.++ - Moderation: staff can switch a world's scripts off.++4. **Teams.** A world gets editors besides its owner. The collaboration socket (`/editor-ws`, staff-only today) opens to a world's team.++5. **The asset store.** Creators publish models, materials, sounds and scripts for others to use and remix, free first.++ - Uploaded models and textures wait on the Pics safety pipeline, as the editor's limits already note.++ - Paid assets come later, priced in Vibes through Billing, with items in Inventory (ADR-054). The owner sets the prices.++6. **2D.** The casual kit is the 2D path. Next, a 2D world type made from the same editor and pack format, so a 2D game is a world too.++7. **The instant-play funnel.** `/play/<game>` already opens with no download and as a guest. To add:++ - campaign landing links with attribution (plan T22);++ - a first load kept under about 8 MB for 3D and the casual budget for 2D;++ - an X ads account and pixel, once the owner sets them up (O36).++8. **Creators get paid.** Engagement-based payouts in Vibes, in the spirit of Roblox Creator Rewards: play time in a creator's published worlds, from the money Billing actually takes in. This needs the owner's decision on the share and the rules.++++## The owner's decisions++++- **Paid chance (O37):** no paid random draws until decided (ADR-055).++- **Asset and pack prices:** the owner types the prices.++- **Creator payouts:** the share, the threshold and the eligibility.++- **Ads (O36):** the account, the budget and the pixel.nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- Remixing a creator world (owner, 2026-10-27: "other people can make games just like the same way you can copy our++-- game … it should be open"): a published world copied into a new draft of the remixer's own, credited.++-- remixed_from is the source's id with no foreign key: the source may be deleted later and the credit stays, with++-- the title and owner it had when it was copied. remixes counts how often a world was remixed by other people.++ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS remixed_from text COLLATE "C";++ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS remixed_from_title text;++ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS remixed_from_owner text;++ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS remixes integer NOT NULL DEFAULT 0;@@@ -37,7 +37,23 @@ describe('creator worlds store', () => {revision: mapRevision(MAP),publishedAt: null,plays: 0,++ remixedFrom: null,++ remixes: 0,})++ // A remix keeps its credit as it was copied; the original counts remixes.++ const remix = await store.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Moon base (remix)',++ summary: '',++ map: MAP,++ now: 1001,++ remixedFrom: { id: world.id, title: 'Moon base', owner: 'kim' },++ })++ expect(remix.remixedFrom).toEqual({ id: world.id, title: 'Moon base', owner: 'kim' })++ await store.countRemix(world.id)++ expect((await store.get(world.id))?.remixes).toBe(1)++ await store.remove(remix.id)expect(await store.countByOwner(KIM)).toBe(1)expect(await store.listPublished(10, 0)).toEqual([])@@@ -23,6 +23,10 @@ export interface CreatorWorld {createdAt: numberupdatedAt: numberpublishedAt: number | null++ /** The world this one was remixed from, as it was then (migration 0013); null for an original. */++ remixedFrom: { id: string; title: string; owner: string } | null++ /** How often other people remixed this world. */++ remixes: number}interface Row {@@@ -37,9 +41,13 @@ interface Row {created_at: number | stringupdated_at: number | stringpublished_at: number | string | null++ remixed_from: string | null++ remixed_from_title: string | null++ remixed_from_owner: string | null++ remixes: number | string | null}--const COLUMNS = sql`id, owner_subject, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at`++const COLUMNS = sql`id, owner_subject, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at, remixed_from, remixed_from_title, remixed_from_owner, remixes`function fromRow(row: Row): CreatorWorld {return {@@@ -54,6 +62,14 @@ function fromRow(row: Row): CreatorWorld {createdAt: Number(row.created_at),updatedAt: Number(row.updated_at),publishedAt: row.published_at === null ? null : Number(row.published_at),++ remixedFrom: row.remixed_from++ ? {++ id: row.remixed_from,++ title: row.remixed_from_title ?? '',++ owner: row.remixed_from_owner ?? '',++ }++ : null,++ remixes: Number(row.remixes ?? 0),}}@@@ -84,6 +100,8 @@ export interface CreatorWorldStore {summary: stringmap: unknownnow: number++ /** A remix: the world it copies, as it is now (kept as the credit). */++ remixedFrom?: { id: string; title: string; owner: string }}): Promise<CreatorWorld>get(id: string): Promise<CreatorWorld | null>/** The stored map document and its revision. */@@@ -110,6 +128,8 @@ export interface CreatorWorldStore {/** The owner's display name follows the Network's (set at each signed-in write). */rename(ownerSubject: string, ownerName: string): Promise<void>countPlay(id: string): Promise<void>++ /** One more remix of this world by someone else. */++ countRemix(id: string): Promise<void>/** Removes the world and its place's rows. */remove(id: string): Promise<boolean>}@@@ -118,10 +138,13 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {return {async create(input) {const id = newCreatorWorldId()++ const from = input.remixedFrom ?? nullconst row = await db.one<Row>(sql`-- INSERT INTO creator_worlds (id, owner_subject, owner_name, title, summary, map, revision, created_at, updated_at)++ INSERT INTO creator_worlds (id, owner_subject, owner_name, title, summary, map, revision, created_at, updated_at,++ remixed_from, remixed_from_title, remixed_from_owner)VALUES (${id}, ${input.ownerSubject}, ${input.ownerName}, ${input.title}, ${input.summary},-- ${sql.json(input.map)}, ${mapRevision(input.map)}, ${input.now}, ${input.now})++ ${sql.json(input.map)}, ${mapRevision(input.map)}, ${input.now}, ${input.now},++ ${from ? from.id : null}, ${from ? from.title : null}, ${from ? from.owner : null})RETURNING ${COLUMNS}`)return fromRow(row)@@@ -192,6 +215,9 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {async countPlay(id) {await db.exec(sql`UPDATE creator_worlds SET plays = plays + 1 WHERE id = ${id}`)},++ async countRemix(id) {++ await db.exec(sql`UPDATE creator_worlds SET remixes = remixes + 1 WHERE id = ${id}`)++ },async remove(id) {return db.tx(async () => {const gone = await db.exec(sql`DELETE FROM creator_worlds WHERE id = ${id}`)
@@ -1,6 +1,10 @@
@@ -1,6 +1,10 @@
export {}
export {}
-/** /worlds: the published creator worlds, and the signed-in player's own (make, publish, edit, delete). */
+/**
+ * /worlds: the published creator worlds, and the signed-in player's own (make, publish, edit, delete, remix). A remix
+ * copies a published world into a new draft of yours, credited (owner, 2026-10-27: "the same way you can copy our
+ * game"); /worlds?remix=<id> (a world page's Remix link) makes one and opens it in the editor, after sign-in if needed.
+ */
interface World {
interface World {
id: string
id: string
@@ -14,6 +18,8 @@ interface World {
@@ -14,6 +18,8 @@ interface World {
play_url: string
play_url: string
status?: 'draft' | 'published' | 'hidden'
status?: 'draft' | 'published' | 'hidden'
edit_url?: string
edit_url?: string
+ remixed_from?: { id: string; title: string; owner: string; url: string } | null
+ remixes?: number
}
}
interface Mine {
interface Mine {
@@ -114,19 +120,58 @@ const count = (n: number, one: string, many: string): string =>
@@ -114,19 +120,58 @@ const count = (n: number, one: string, many: string): string =>
function stats(world: World): string {
function stats(world: World): string {
const parts = [count(world.plays, 'visit', 'visits')]
const parts = [count(world.plays, 'visit', 'visits')]
if (world.online > 0) parts.unshift(`${world.online} playing now`)
if (world.online > 0) parts.unshift(`${world.online} playing now`)
+ if (world.remixes) parts.push(count(world.remixes, 'remix', 'remixes'))
return parts.join(' · ')
return parts.join(' · ')
}
}
+/** "Remix of <title> by <owner>", the credit a remix carries (null for an original). */
+function credit(world: World): HTMLElement | null {
+ const from = world.remixed_from
+ if (!from) return null
+ return el(
+ 'p',
+ { className: 'by' },
+ document.createTextNode('Remix of '),
+ link(from.title || 'a world', from.url, false, 'inline'),
+ document.createTextNode(` by ${from.owner}`),
+ )
+}
+
+const remixStatus = (): HTMLElement => (signedIn.hidden ? publishedStatus : createStatus)
+
+/** Copy a world into a new draft of yours and open it in the editor. */
+async function remixWorld(id: string, b?: HTMLButtonElement): Promise<void> {
+ if (b) b.disabled = true
+ remixStatus().textContent = 'Making your remix…'
+ try {
+ const world = await api<World>(`/api/worlds/${encodeURIComponent(id)}/remix`, {
+ method: 'POST',
+ })
+ remixStatus().textContent = ''
+ if (world.edit_url) location.href = world.edit_url
+ } catch (err) {
+ remixStatus().textContent = err instanceof Error ? err.message : 'That did not work.'
+ if (b) b.disabled = false
+ }
+}
+
function publishedCard(world: World, staff: boolean): HTMLElement {
function publishedCard(world: World, staff: boolean): HTMLElement {
const card = el(
const card = el(
'article',
'article',
{ className: 'card' },
{ className: 'card' },
titleOf(world),
titleOf(world),
el('p', { className: 'by', text: `by ${world.owner}` }),
el('p', { className: 'by', text: `by ${world.owner}` }),
+ credit(world),
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
el('p', { className: 'stats', text: stats(world) }),
el('p', { className: 'stats', text: stats(world) }),
)
)
const actions = el('div', { className: 'actions' }, link('▶ Play', world.play_url, true))
const actions = el('div', { className: 'actions' }, link('▶ Play', world.play_url, true))
+ // Anyone signed in may copy a published world into a world of their own; signed out, the link signs in first.
+ if (token()) actions.append(button('Remix', (b) => void remixWorld(world.id, b)))
+ else
+ actions.append(
+ link('Remix', `/auth/login?next=${encodeURIComponent(`/worlds?remix=${world.id}`)}`),
+ )
if (staff)
if (staff)
actions.append(
actions.append(
button(
button(
@@ -147,6 +192,7 @@ function mineCard(world: World): HTMLElement {
@@ -147,6 +192,7 @@ function mineCard(world: World): HTMLElement {
{ className: 'card' },
{ className: 'card' },
el('span', { className: `badge ${status}`, text: label }),
el('span', { className: `badge ${status}`, text: label }),
titleOf(world),
titleOf(world),
+ credit(world),
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
el('p', { className: 'stats', text: stats(world) }),
el('p', { className: 'stats', text: stats(world) }),
)
)
@@ -288,4 +334,21 @@ moreButton.addEventListener('click', () => {
@@ -288,4 +334,21 @@ moreButton.addEventListener('click', () => {
void loadPublished()
void loadPublished()
})
})
-void loadMine().then(() => loadPublished(true))
+void loadMine().then(() => {
+ // A world page's Remix link: make the copy and open it, or sign in first and come back here to do it.
+ const want = new URLSearchParams(location.search).get('remix')
+ if (want && /^w[a-z0-9]{10}$/.test(want)) {
+ if (!signedIn.hidden) {
+ history.replaceState(null, '', '/worlds')
+ void remixWorld(want)
+ } else {
+ const signIn = signedOut.querySelector<HTMLAnchorElement>('a.button')
+ if (signIn) signIn.href = `/auth/login?next=${encodeURIComponent(`/worlds?remix=${want}`)}`
+ const note = signedOut.querySelector('.status')
+ if (note)
+ note.textContent =
+ 'Sign in with your OpenVibe account to remix that world into one of your own.'
+ }
+ }
+ return loadPublished(true)
+})
@@ -56,6 +56,8 @@ const world = (id: string, plays: number, publishedAt: number): CreatorWorld =>
@@ -56,6 +56,8 @@ const world = (id: string, plays: number, publishedAt: number): CreatorWorld =>
createdAt: publishedAt,
createdAt: publishedAt,
updatedAt: publishedAt,
updatedAt: publishedAt,
publishedAt,
publishedAt,
+ remixedFrom: null,
+ remixes: 0,
})
})
describe('the home page live sections', () => {
describe('the home page live sections', () => {
@@ -128,6 +128,8 @@ describe('creator worlds in OpenVibe.Search', () => {
@@ -128,6 +128,8 @@ describe('creator worlds in OpenVibe.Search', () => {
createdAt: 1,
createdAt: 1,
updatedAt: 2,
updatedAt: 2,
publishedAt: 2,
publishedAt: 2,
+ remixedFrom: null,
+ remixes: 0,
},
},
'https://openvibe.games',
'https://openvibe.games',
shell,
shell,
@@ -138,5 +140,30 @@ describe('creator worlds in OpenVibe.Search', () => {
@@ -138,5 +140,30 @@ describe('creator worlds in OpenVibe.Search', () => {
expect(html).toContain('href="/play?world=wk3j9x0a2bq"')
expect(html).toContain('href="/play?world=wk3j9x0a2bq"')
expect(html).toContain('<link rel="canonical" href="https://openvibe.games/worlds/wk3j9x0a2bq"')
expect(html).toContain('<link rel="canonical" href="https://openvibe.games/worlds/wk3j9x0a2bq"')
expect(html).toContain('1 visit')
expect(html).toContain('1 visit')
+ expect(html).toContain('href="/worlds?remix=wk3j9x0a2bq"')
+ expect(html).not.toContain('Remix of')
+ const remix = worldPageHtml(
+ {
+ id: 'wremix00001',
+ ownerSubject: KIM,
+ ownerName: 'lee',
+ title: 'Moon (remix)',
+ summary: '',
+ status: 'published',
+ revision: 'r',
+ plays: 0,
+ createdAt: 1,
+ updatedAt: 2,
+ publishedAt: 2,
+ remixedFrom: { id: 'wk3j9x0a2bq', title: 'Moon <script>', owner: 'kim <b>' },
+ remixes: 2,
+ },
+ 'https://openvibe.games',
+ shell,
+ )
+ expect(remix).toContain(
+ 'Remix of <a href="/worlds/wk3j9x0a2bq">Moon <script></a> by kim <b>',
+ )
+ expect(remix).toContain('0 visits · 2 remixes')
})
})
})
})
@@ -38,7 +38,11 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
@@ -38,7 +38,11 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
const description =
const description =
world.summary ||
world.summary ||
`A creator world by ${world.ownerName} on OpenVibe.Games. Join it from your browser.`
`A creator world by ${world.ownerName} on OpenVibe.Games. Join it from your browser.`
- const visits = `${world.plays.toLocaleString('en-US')} ${world.plays === 1 ? 'visit' : 'visits'}`
+ const visits = `${world.plays.toLocaleString('en-US')} ${world.plays === 1 ? 'visit' : 'visits'}${
+ world.remixes
+ ? ` · ${world.remixes.toLocaleString('en-US')} ${world.remixes === 1 ? 'remix' : 'remixes'}`
+ : ''
+ }`
const links = [
const links = [
{ label: 'Creator worlds', href: '/worlds' },
{ label: 'Creator worlds', href: '/worlds' },
{ label: 'Play Scraplandia', href: '/play' },
{ label: 'Play Scraplandia', href: '/play' },
@@ -73,14 +77,17 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
@@ -73,14 +77,17 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
<p class="eyebrow">CREATOR WORLD</p>
<p class="eyebrow">CREATOR WORLD</p>
<h1>${escapeHtml(world.title)}</h1>
<h1>${escapeHtml(world.title)}</h1>
<p class="by">by ${escapeHtml(world.ownerName)}</p>
<p class="by">by ${escapeHtml(world.ownerName)}</p>
+ ${world.remixedFrom ? `<p class="by">Remix of <a href="/worlds/${escapeHtml(world.remixedFrom.id)}">${escapeHtml(world.remixedFrom.title)}</a> by ${escapeHtml(world.remixedFrom.owner)}</p>` : ''}
${world.summary ? `<p class="summary">${escapeHtml(world.summary)}</p>` : ''}
${world.summary ? `<p class="summary">${escapeHtml(world.summary)}</p>` : ''}
<p class="stats">${escapeHtml(visits)}</p>
<p class="stats">${escapeHtml(visits)}</p>
<div class="row">
<div class="row">
<a class="play" href="/play?world=${world.id}">▶ Join this world</a>
<a class="play" href="/play?world=${world.id}">▶ Join this world</a>
+ <a class="more" href="/worlds?remix=${world.id}">Remix it</a>
<a class="more" href="/worlds">More worlds</a>
<a class="more" href="/worlds">More worlds</a>
</div>
</div>
<p class="note">Plays in your browser with no download, as a guest or with your OpenVibe account. A world is a
<p class="note">Plays in your browser with no download, as a guest or with your OpenVibe account. A world is a
- sandbox: what you gather or build inside it stays in it.</p>
+ sandbox: what you gather or build inside it stays in it. Remix it to start a world of your own from this one; the
+ remix credits it.</p>
</main>`,
</main>`,
})
})
}
}
@@ -124,6 +124,56 @@ describe('/api/worlds', () => {
@@ -124,6 +124,56 @@ describe('/api/worlds', () => {
expect(parseIfMatch('')).toBeNull()
expect(parseIfMatch('')).toBeNull()
})
})
+ it("remixes a published world into a new draft of the remixer's own, credited and counted", async () => {
+ const { call } = await setup(2)
+ const made = await call('POST', '/api/worlds', 'kim', {
+ title: 'Moon base',
+ summary: 'Low gravity vibes',
+ })
+ const id = String(made.body.id)
+ const map = await call('GET', `/api/worlds/${id}/map`, 'kim')
+ const next: MapFileV2 = { ...emptyMapV2(), spawn: [7, 1, 7] }
+ await call('PUT', `/api/worlds/${id}/map`, 'kim', next, { 'if-match': map.etag ?? '' })
+
+ // Signed in only, and never someone else's draft.
+ expect((await call('POST', `/api/worlds/${id}/remix`)).status).toBe(401)
+ expect((await call('POST', `/api/worlds/${id}/remix`, 'guest')).status).toBe(401)
+ expect((await call('POST', `/api/worlds/${id}/remix`, 'lee')).status).toBe(404)
+ expect((await call('POST', '/api/worlds/wnothere0000/remix', 'lee')).status).toBe(404)
+
+ await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })
+ const remix = await call('POST', `/api/worlds/${id}/remix`, 'lee')
+ expect(remix.status).toBe(201)
+ const rid = String(remix.body.id)
+ expect(rid).not.toBe(id)
+ expect(remix.body).toMatchObject({
+ title: 'Moon base (remix)',
+ summary: 'Low gravity vibes',
+ status: 'draft',
+ edit_url: `/editor?world=${rid}`,
+ remixed_from: { id, title: 'Moon base', owner: 'kim', url: `/worlds/${id}` },
+ remixes: 0,
+ })
+ // The copy is the remixer's: the same map, editable by them, not by the original's owner.
+ const copy = await call('GET', `/api/worlds/${rid}/map`, 'lee')
+ expect(copy.status).toBe(200)
+ expect((copy.body as unknown as MapFileV2).spawn).toEqual([7, 1, 7])
+ expect((await call('GET', `/api/worlds/${rid}/map`, 'kim')).status).toBe(404)
+ // The original counts it, publicly.
+ const listed = (await call('GET', '/api/worlds')).body.worlds ?? []
+ expect(listed.find((w) => w.id === id)).toMatchObject({ remixes: 1, remixed_from: null })
+
+ // Remixing your own world makes a copy without counting; the per-person limit still holds.
+ const own = await call('POST', `/api/worlds/${id}/remix`, 'kim')
+ expect(own.status).toBe(201)
+ expect(
+ ((await call('GET', '/api/worlds')).body.worlds ?? []).find((w) => w.id === id),
+ ).toMatchObject({ remixes: 1 })
+ const full = await call('POST', `/api/worlds/${id}/remix`, 'kim')
+ expect(full.status).toBe(409)
+ expect(full.body.error).toBe('world_limit')
+ })
+
it('lets a signed-in person make, save, publish and delete a world; staff take one down', async () => {
it('lets a signed-in person make, save, publish and delete a world; staff take one down', async () => {
const { call, calls } = await setup()
const { call, calls } = await setup()
expect((await call('POST', '/api/worlds', undefined, { title: 'x' })).status).toBe(401)
expect((await call('POST', '/api/worlds', undefined, { title: 'x' })).status).toBe(401)
@@ -124,6 +124,16 @@ function publicView(world: CreatorWorld, online: number) {
@@ -124,6 +124,16 @@ function publicView(world: CreatorWorld, online: number) {
published_at: world.publishedAt === null ? null : new Date(world.publishedAt).toISOString(),
published_at: world.publishedAt === null ? null : new Date(world.publishedAt).toISOString(),
updated_at: new Date(world.updatedAt).toISOString(),
updated_at: new Date(world.updatedAt).toISOString(),
play_url: `/play?world=${world.id}`,
play_url: `/play?world=${world.id}`,
+ // A remix credits the world it copied (as it was then); remixes counts how often others copied this one.
+ remixed_from: world.remixedFrom
+ ? {
+ id: world.remixedFrom.id,
+ title: world.remixedFrom.title,
+ owner: world.remixedFrom.owner,
+ url: `/worlds/${world.remixedFrom.id}`,
+ }
+ : null,
+ remixes: world.remixes,
}
}
}
}
@@ -303,6 +313,38 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -303,6 +313,38 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
return
return
}
}
+ // POST /api/worlds/:id/remix: a published world (or one of your own) copied into a new draft of yours, credited
+ // (owner, 2026-10-27: "other people can make games just like the same way you can copy our game"). The copy is
+ // yours to change and publish; the original is untouched and counts the remix.
+ const remix = /^\/api\/worlds\/([^/]+)\/remix$/.exec(path)
+ if (remix && method === 'POST') {
+ const user = await signedIn(req)
+ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return
+ const source = await opts.store.get(remix[1] ?? '')
+ const own = !!source && source.ownerSubject === user.subjectId
+ if (!source || (source.status !== 'published' && !own)) fail(404, 'not_found')
+ if ((await opts.store.countByOwner(user.subjectId)) >= opts.perPerson)
+ fail(409, 'world_limit', {
+ detail: `You can keep ${opts.perPerson} worlds. Delete one to make another.`,
+ })
+ const stored = await opts.store.map((source as CreatorWorld).id)
+ if (!stored) fail(404, 'not_found')
+ const src = source as CreatorWorld
+ const world = await opts.store.create({
+ ownerSubject: user.subjectId,
+ ownerName: user.name,
+ title: `${src.title.slice(0, TITLE_MAX - 8).trimEnd()} (remix)`,
+ summary: src.summary,
+ map: (stored as { map: unknown }).map,
+ now: now(),
+ remixedFrom: { id: src.id, title: src.title, owner: src.ownerName },
+ })
+ if (!own) await opts.store.countRemix(src.id)
+ opts.log.info('creator world remixed', { world: world.id, from: src.id })
+ send(res, 201, ownerView(world, 0))
+ return
+ }
+
const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)
const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)
if (!match) fail(404, 'not_found')
if (!match) fail(404, 'not_found')
const id = match?.[1] ?? ''
const id = match?.[1] ?? ''
@@ -18,6 +18,7 @@ Scraplandia's world is **Scrap Island**, a map document like any the editor save
@@ -18,6 +18,7 @@ Scraplandia's world is **Scrap Island**, a map document like any the editor save
The generator is `apps/server/src/maps/scrapIsland.ts`. It is deterministic, and it refuses to overwrite an existing
The generator is `apps/server/src/maps/scrapIsland.ts`. It is deterministic, and it refuses to overwrite an existing
file without `--force`, because that file may hold staff edits.
file without `--force`, because that file may hold staff edits.
+
- **Terrain**: one 420 m heightfield (2 m cells) above the sea at `WATER_LEVEL`, painted grass, rock (slopes, heights)
- **Terrain**: one 420 m heightfield (2 m cells) above the sea at `WATER_LEVEL`, painted grass, rock (slopes, heights)
and sand (shores) over a dirt base. The dirt shows on the roads.
and sand (shores) over a dirt base. The dirt shows on the roads.
- **Scrap City** (the safe zone `scrap-city`, walls at ±38.5): the plaza and fountain, four market stalls, a workbench,
- **Scrap City** (the safe zone `scrap-city`, walls at ±38.5): the plaza and fountain, four market stalls, a workbench,
@@ -87,6 +88,12 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -87,6 +88,12 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
- **Sandbox:** characters are read but never saved in a creator world (`worlds/sandboxStore.ts`), and the room has
- **Sandbox:** characters are read but never saved in a creator world (`worlds/sandboxStore.ts`), and the room has
no NPCs, world events, rewards, boards or events. Nothing gathered, crafted, traded or dropped in somebody's world
no NPCs, world events, rewards, boards or events. Nothing gathered, crafted, traded or dropped in somebody's world
reaches Scraplandia, the leaderboards or the Inventory. The world's own rows (props, constraints, clock) persist.
reaches Scraplandia, the leaderboards or the Inventory. The world's own rows (props, constraints, clock) persist.
+- **Remix:** `POST /api/worlds/:id/remix` copies a published world (or one of your own) into a new draft of yours,
+ titled "… (remix)", within your world limit. It is yours to change and publish. The copy keeps the credit
+ (`remixed_from`, with the title and owner as they were when copied; migration 0013), shown on its page and cards,
+ and the original counts `remixes` from other people. A world page's **Remix it** link goes through
+ `/worlds?remix=<id>`, which signs you in first if needed and opens the copy in the editor (owner, 2026-10-27: "other
+ people can make games just like the same way you can copy our game").
- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff
- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff
(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the owner
(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the owner
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+# OpenVibe.Games as an open game platform
+
+The owner, 2026-10-27:
+
+- "we want to make it more modular open to modder it's more like Gary's mod or the new s&box game or Roblox … make more of a game engine both 3D and 2D … monetizable using openvibe systems";
+- instant-play browser games that "x.com or Twitter ads" link to, with cheap coin packs (the g123.jp model: no download, play on the site);
+- "other people can make games just like the same way you can copy our game … make teams … asset store".
+
+## What the market does (researched 2026-10-27)
+
+**s&box (Facepunch, Garry's Mod's successor)**
+
+- Launched on Steam on 2026-04-28. It runs on Source 2, has C# scripting, an editor and built-in multiplayer, and is MIT-licensed.
+- Facepunch had paid creators $500k by launch. A March 2026 Valve agreement lets editor projects ship as royalty-free standalone Steam games.
+- Reported since then: a move to free-to-play by the end of 2026, after paid access and cosmetics put players off; weak retention; open books showing a loss.
+- Lessons: openness and creator pay draw creators, but a paid door and a thin player base hurt.
+
+**Roblox**
+
+- Creators earn through engagement payouts (Creator Rewards, from the play time of Premium and ad-eligible users) as well as their own sales. Robux cash out through DevEx.
+- Payout rates rose 8.5% in September 2025. Third-party estimates put creator payouts at roughly 31% of revenue (unaudited).
+- Lesson: pay creators for engagement, not only for sales, so free games are worth building.
+
+**Poki and CrazyGames (instant web play)**
+
+- HTML5 games, curated, ad-funded.
+- Reported splits: Poki 100% to the developer for players they bring and 50/50 for players Poki brings. CrazyGames is cited at 60% of ad revenue and 70% of purchases (jam terms).
+- Poki asks for a first download under about 8 MB.
+- Lesson: a small first load and no install are the price of entry.
+
+**G123 (CTW, Japan)**
+
+- HTML5 anime games with no download, no install and no required sign-up. It reports over 500M users.
+- It is known for heavy social-media ads, and a UK ASA investigation in 2025 found some unsuitable. The Nasdaq stock is down about half since its IPO.
+- Lesson: the funnel works, but its ads damage the brand. Our rules already forbid what drew the complaints: nothing sexual with minor-looking characters, original characters only.
+
+Sources:
+
+- [GamingOnLinux, s&box launch](https://www.gamingonlinux.com/2026/04/facepunch-launches-s-box-the-highly-anticipated-successor-to-garrys-mod/page=1/)
+- [games.gg, s&box creator payouts](https://games.gg/nl/news/sbox-lanceert-op-steam-met-duurzame-uitbetalingen-aan-makers-en-kritiek-op-epic/)
+- [VGTimes, s&box going free](https://vgtimes.ru/gaming-news/171063-sbox-stanet-besplatnoy-naslednica-garrys-mod-otkazyvaetsya-ot-platnogo-vhoda.html)
+- [Nasdaq, Roblox payouts](https://www.nasdaq.com/articles/roblox-raises-creator-payouts-will-margins-face-pressure-2026)
+- [Cinevva, publishing on Poki](https://app.cinevva.com/guides/publish-game-poki)
+- [Cinevva, web game monetization](https://app.cinevva.com/guides/web-game-monetization)
+- [Wikipedia, CTW Inc.](https://en.wikipedia.org/wiki/CTW_Inc.)
+
+## Where Games stands
+
+Already here:
+
+- **Content packs and mods:** content packs (`games-content@2`, declarative mods with per-capability grants) and server script mods (`games-quickjs@1`, with capabilities and CPU, memory and stack budgets); see `mods.md`.
+- **Creator worlds:**
+ - an editor, publishing, its own pages, Search and Quest events;
+ - **remix** (copy a world into your own, credited);
+ - characters that belong to the game (no platform slot cap, since 2026-10-27).
+- **The casual kit (`casual.md`):** small DOM games with no Babylon and about 26 KB gzip, guest play from a link, campaign numbers, and earned-only summons.
+
+## The plan, in order
+
+1. **Characters belong to the game.** Done (Games#158): no "3 slots" door, a 24 technical ceiling. Next, a game's pack may declare its own character rule, read by the shell.
+2. **Remix.** Done: copy, credit, count.
+3. **Worlds with scripts.** The Garry's Mod moment.
+ - A creator world may carry `games-quickjs@1` mods, with the same capability grants and budgets as packs, sandboxed to that world.
+ - Its owner can edit them from the editor.
+ - Moderation: staff can switch a world's scripts off.
+4. **Teams.** A world gets editors besides its owner. The collaboration socket (`/editor-ws`, staff-only today) opens to a world's team.
+5. **The asset store.** Creators publish models, materials, sounds and scripts for others to use and remix, free first.
+ - Uploaded models and textures wait on the Pics safety pipeline, as the editor's limits already note.
+ - Paid assets come later, priced in Vibes through Billing, with items in Inventory (ADR-054). The owner sets the prices.
+6. **2D.** The casual kit is the 2D path. Next, a 2D world type made from the same editor and pack format, so a 2D game is a world too.
+7. **The instant-play funnel.** `/play/<game>` already opens with no download and as a guest. To add:
+ - campaign landing links with attribution (plan T22);
+ - a first load kept under about 8 MB for 3D and the casual budget for 2D;
+ - an X ads account and pixel, once the owner sets them up (O36).
+8. **Creators get paid.** Engagement-based payouts in Vibes, in the spirit of Roblox Creator Rewards: play time in a creator's published worlds, from the money Billing actually takes in. This needs the owner's decision on the share and the rules.
+
+## The owner's decisions
+
+- **Paid chance (O37):** no paid random draws until decided (ADR-055).
+- **Asset and pack prices:** the owner types the prices.
+- **Creator payouts:** the share, the threshold and the eligibility.
+- **Ads (O36):** the account, the budget and the pixel.
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- Remixing a creator world (owner, 2026-10-27: "other people can make games just like the same way you can copy our
+-- game … it should be open"): a published world copied into a new draft of the remixer's own, credited.
+-- remixed_from is the source's id with no foreign key: the source may be deleted later and the credit stays, with
+-- the title and owner it had when it was copied. remixes counts how often a world was remixed by other people.
+ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS remixed_from text COLLATE "C";
+ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS remixed_from_title text;
+ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS remixed_from_owner text;
+ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS remixes integer NOT NULL DEFAULT 0;
@@ -37,7 +37,23 @@ describe('creator worlds store', () => {
@@ -37,7 +37,23 @@ describe('creator worlds store', () => {
revision: mapRevision(MAP),
revision: mapRevision(MAP),
publishedAt: null,
publishedAt: null,
plays: 0,
plays: 0,
+ remixedFrom: null,
+ remixes: 0,
})
})
+ // A remix keeps its credit as it was copied; the original counts remixes.
+ const remix = await store.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Moon base (remix)',
+ summary: '',
+ map: MAP,
+ now: 1001,
+ remixedFrom: { id: world.id, title: 'Moon base', owner: 'kim' },
+ })
+ expect(remix.remixedFrom).toEqual({ id: world.id, title: 'Moon base', owner: 'kim' })
+ await store.countRemix(world.id)
+ expect((await store.get(world.id))?.remixes).toBe(1)
+ await store.remove(remix.id)
expect(await store.countByOwner(KIM)).toBe(1)
expect(await store.countByOwner(KIM)).toBe(1)
expect(await store.listPublished(10, 0)).toEqual([])
expect(await store.listPublished(10, 0)).toEqual([])
@@ -23,6 +23,10 @@ export interface CreatorWorld {
@@ -23,6 +23,10 @@ export interface CreatorWorld {
createdAt: number
createdAt: number
updatedAt: number
updatedAt: number
publishedAt: number | null
publishedAt: number | null
+ /** The world this one was remixed from, as it was then (migration 0013); null for an original. */
+ remixedFrom: { id: string; title: string; owner: string } | null
+ /** How often other people remixed this world. */
+ remixes: number
}
}
interface Row {
interface Row {
@@ -37,9 +41,13 @@ interface Row {
@@ -37,9 +41,13 @@ interface Row {
created_at: number | string
created_at: number | string
updated_at: number | string
updated_at: number | string
published_at: number | string | null
published_at: number | string | null
+ remixed_from: string | null
+ remixed_from_title: string | null
+ remixed_from_owner: string | null
+ remixes: number | string | null
}
}
-const COLUMNS = sql`id, owner_subject, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at`
+const COLUMNS = sql`id, owner_subject, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at, remixed_from, remixed_from_title, remixed_from_owner, remixes`
function fromRow(row: Row): CreatorWorld {
function fromRow(row: Row): CreatorWorld {
return {
return {
@@ -54,6 +62,14 @@ function fromRow(row: Row): CreatorWorld {
@@ -54,6 +62,14 @@ function fromRow(row: Row): CreatorWorld {
createdAt: Number(row.created_at),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
updatedAt: Number(row.updated_at),
publishedAt: row.published_at === null ? null : Number(row.published_at),
publishedAt: row.published_at === null ? null : Number(row.published_at),
+ remixedFrom: row.remixed_from
+ ? {
+ id: row.remixed_from,
+ title: row.remixed_from_title ?? '',
+ owner: row.remixed_from_owner ?? '',
+ }
+ : null,
+ remixes: Number(row.remixes ?? 0),
}
}
}
}
@@ -84,6 +100,8 @@ export interface CreatorWorldStore {
@@ -84,6 +100,8 @@ export interface CreatorWorldStore {
summary: string
summary: string
map: unknown
map: unknown
now: number
now: number
+ /** A remix: the world it copies, as it is now (kept as the credit). */
+ remixedFrom?: { id: string; title: string; owner: string }
}): Promise<CreatorWorld>
}): Promise<CreatorWorld>
get(id: string): Promise<CreatorWorld | null>
get(id: string): Promise<CreatorWorld | null>
/** The stored map document and its revision. */
/** The stored map document and its revision. */
@@ -110,6 +128,8 @@ export interface CreatorWorldStore {
@@ -110,6 +128,8 @@ export interface CreatorWorldStore {
/** The owner's display name follows the Network's (set at each signed-in write). */
/** The owner's display name follows the Network's (set at each signed-in write). */
rename(ownerSubject: string, ownerName: string): Promise<void>
rename(ownerSubject: string, ownerName: string): Promise<void>
countPlay(id: string): Promise<void>
countPlay(id: string): Promise<void>
+ /** One more remix of this world by someone else. */
+ countRemix(id: string): Promise<void>
/** Removes the world and its place's rows. */
/** Removes the world and its place's rows. */
remove(id: string): Promise<boolean>
remove(id: string): Promise<boolean>
}
}
@@ -118,10 +138,13 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
@@ -118,10 +138,13 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
return {
return {
async create(input) {
async create(input) {
const id = newCreatorWorldId()
const id = newCreatorWorldId()
+ const from = input.remixedFrom ?? null
const row = await db.one<Row>(sql`
const row = await db.one<Row>(sql`
- INSERT INTO creator_worlds (id, owner_subject, owner_name, title, summary, map, revision, created_at, updated_at)
+ INSERT INTO creator_worlds (id, owner_subject, owner_name, title, summary, map, revision, created_at, updated_at,
+ remixed_from, remixed_from_title, remixed_from_owner)
VALUES (${id}, ${input.ownerSubject}, ${input.ownerName}, ${input.title}, ${input.summary},
VALUES (${id}, ${input.ownerSubject}, ${input.ownerName}, ${input.title}, ${input.summary},
- ${sql.json(input.map)}, ${mapRevision(input.map)}, ${input.now}, ${input.now})
+ ${sql.json(input.map)}, ${mapRevision(input.map)}, ${input.now}, ${input.now},
+ ${from ? from.id : null}, ${from ? from.title : null}, ${from ? from.owner : null})
RETURNING ${COLUMNS}
RETURNING ${COLUMNS}
`)
`)
return fromRow(row)
return fromRow(row)
@@ -192,6 +215,9 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
@@ -192,6 +215,9 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
async countPlay(id) {
async countPlay(id) {
await db.exec(sql`UPDATE creator_worlds SET plays = plays + 1 WHERE id = ${id}`)
await db.exec(sql`UPDATE creator_worlds SET plays = plays + 1 WHERE id = ${id}`)
},
},
+ async countRemix(id) {
+ await db.exec(sql`UPDATE creator_worlds SET remixes = remixes + 1 WHERE id = ${id}`)
+ },
async remove(id) {
async remove(id) {
return db.tx(async () => {
return db.tx(async () => {
const gone = await db.exec(sql`DELETE FROM creator_worlds WHERE id = ${id}`)
const gone = await db.exec(sql`DELETE FROM creator_worlds WHERE id = ${id}`)