Overview

Pupkins Create lets you write game logic as a deterministic tick function and draw the page however you like — three.js, canvas, plain HTML, anything that runs in a browser. The platform handles online play, rooms, matchmaking, and reconnect.

There are four game modes:

  • Rollback — real-time prediction. Every screen runs the logic and predicts ahead; corrections are invisible at normal latency. Standard for real-time games with a few players.
  • Lockstep — for big games with armies of units. Steps once when every player's input is in.
  • Turns — hidden information. Only the room runs the logic; each screen sees a filtered view. No secrets leak.
  • Presence — social worlds with no rules. Each screen publishes its player's position; the platform syncs everyone.

Pick a starter, change the rules, and ship it. The starters are working games with online play, characters, a scene, and a menu — covering 2D, 3D, cards, social worlds, lockstep squads, and minigame parties.

Quick Start

pupkins-create starters                  # list available starters
pupkins-create new my-game               # create a new game from a starter
pupkins-create new my-game --remote      # same, with remote dev: live preview URL, no local server
npm run dev                              # run the dev server with live reload
npm run dev -- --remote                  # watch mode: sync to live preview on every file change
npm run edit                             # open the scene editor
npm run check                            # determinism check, online match, screenshots
npm run playtest                         # run many bot games with a report
pupkins-create share                     # public tunnel link for friends (or use the dev preview URL)
pupkins-create publish                   # send to the build service
pupkins-create release                   # make the last passing build live
pupkins-create logs                      # faults, room errors, play counts
pupkins-create props barrel              # search the 3D prop catalog
pupkins-create login                     # sign in to the platform
pupkins-create doctor                    # check prerequisites
CommandWhat it does
startersLists every starter you can choose from.
new my-gameScaffolds a working game in my-game/. Pick a starter when prompted, or pass --starter <name>.
new my-game --remoteSame, with remote dev: syncs to a live preview URL, no local server needed.
npm run devDev server at http://127.0.0.1:5300/ (standalone) and http://localhost:5300/ (hub emulator with online rooms).
npm run dev -- --remoteWatch mode: syncs to the live preview URL on every file change. No local server.
npm run editOpens the scene editor on the dev server at /edit.
npm run checkValidates determinism, plays an online match in headless Chrome, and screenshots the page.
npm run playtestRuns many bot games with numbers, screenshots, and a report.
shareCreates a public tunnel link anyone can open. With remote dev, the preview URL already works — no tunnel needed.
publishSends the game to the build service. Built in an isolated container.
releaseMakes the last passing build live. release <hash> to roll back. release --off to take down.
logsShows faults, room errors, and play counts. Last 7 days by default; --days 30 for more.
props <words>Searches the 3D prop catalog by keyword.
loginSigns in to the platform from the CLI.
doctorChecks Node version, Chrome, and other prerequisites.

Remote Dev Mode

Remote dev mode syncs game files to the platform on every edit, serving them at a stable preview URL (dev--<game>.ugc.pupkins.gg) in seconds — no local server, room host, or tunnel needed. Multiplayer works: two browser tabs on the dev URL play each other through the platform's rooms.

Getting started

pupkins-create new my-game --starter arena --remote   # scaffold + first sync; prints the dev URL

With --remote, the game syncs immediately and every file edit after that syncs automatically. The SDK doesn't need to be installed locally: the content worker serves it at the game's origin.

How it works

  • Claude Code: A PostToolUse hook syncs after every file edit — no command to run.
  • Other editors: npm run dev -- --remote watches the folder and syncs on every change (500 ms debounce). Ctrl+C stops it.
  • Sharing: The dev preview URL is a stable link you can send to friends. No share command or tunnel needed. Friends reload to see changes.

What stays the same

  • npm run check still runs the full check suite locally.
  • pupkins-create publish still sends the game to the build service.
  • npm run edit still opens the scene editor on a local dev server.

Limits

LimitValue
Syncs per minute60 per creator
Max sync size25 MB
Dev build TTL24 hours (auto-cleaned if no new sync)

Game Structure

my-game/
  logic/                  # Deterministic rules (the tick function)
  client/                 # What players see (drawing, sound, menus, input)
  scenes/                 # 3D/2D layouts (edited in the browser)
  pupkins.game.json       # Manifest: id, name, seats, mode

logic/ holds the rules every screen and the room run identically. client/ is the page: three.js, canvas, whatever you draw with. scenes/ are levels laid out in the scene editor. pupkins.game.json is the game's manifest — its id, display name, player count, and mode.

Logic API

defineLogic

Import defineLogic from @pupkins/create/logic and export a default logic object. Every field:

FieldDescription
modeGame mode: 'rollback' (default), 'lockstep', 'turns', or 'presence'.
seatsNumber of player seats (e.g. 2, 4, or [2, 4] for a range).
setup(match)Returns the initial game state. match.seed provides a deterministic RNG seed.
step(state, inputs)One tick. Mutate the state in place. inputs[seat] is each player's input.
result(state, why)Returns null (game continues) or a result object ({ winner: 0 }, { draw: true }). why is 'tick', 'timeout', or 'leave'.
inputInput shape. { bits: 3, cpu: 4 } for rollback (small integers). { length: 10 } for lockstep (list of orders).
optionsRoom options players can configure. { points: { values: [3, 5, 7], default: 5, label: 'Points to win' } }.
teamsTeam definitions, if the game has teams.
bot(state, seat)Returns an input for a CPU seat. Called every tick for each empty seat.
cpuSeatWhich seats are CPU by default.
tickHzTicks per second (e.g. 60). Rollback and lockstep only.
maxTicksMaximum ticks before the match ends.
checksumCustom checksum function for desync detection.
cloneCustom state cloning function.
eventsEvent definitions emitted during gameplay.
versionBump when the rules change. Rooms reject mismatched versions.

Example

import { defineLogic, fixed } from '@pupkins/create/logic';

export default defineLogic({
  id: 'my-game', version: 1, seats: 2, tickHz: 60,
  input: { bits: 3, cpu: 4 },
  options: { points: { values: [3, 5, 7], default: 5, label: 'Points to win' } },
  setup(match) { return { tick: 0, rng: match.seed | 0 }; },
  step(state, inputs) { /* one tick; mutate the state */ },
  result(state, why) { return gameOver(state) ? { winner: 0 } : null; },
  bot(state, seat) { return 0; },
});

fixed — deterministic integer math

Import fixed from @pupkins/create/logic. Every function uses whole numbers only — no floats, no platform differences.

FunctionDescription
fixed.idiv(a, b)Integer division (truncates toward zero).
fixed.mulDiv(a, b, c)a * b / c without intermediate overflow.
fixed.isqrt(n)Integer square root.
fixed.dist2(dx, dy)Squared distance (dx*dx + dy*dy).
fixed.idist(dx, dy)Integer distance (approximate).
fixed.octile(dx, dy)Octile distance heuristic.
fixed.isin(angle)Integer sine (angle in fixed-point units).
fixed.icos(angle)Integer cosine.
fixed.iatan2(y, x)Integer atan2.
fixed.angleDelta(a, b)Shortest signed angle difference.
fixed.turnToward(current, target, speed)Rotate toward a target angle at a given speed.
fixed.scaleTo(dx, dy, length)Scale a vector to a given length.
fixed.rand(state)Next random integer. Mutates state.rng.
fixed.randInt(state, n)Random integer in [0, n).
fixed.randRange(state, min, max)Random integer in [min, max].
fixed.hashString(str)Deterministic hash of a string.
fixed.hashInts(arr)Deterministic hash of an integer array.

dmath — deterministic floating-point

When you need floats that agree across all engines, import dmath from @pupkins/create/logic. These use a software implementation that produces bit-identical results everywhere.

FunctionDescription
dmath.dsqrt(x)Deterministic square root.
dmath.dsin(x)Deterministic sine.
dmath.dcos(x)Deterministic cosine.
dmath.datan2(y, x)Deterministic atan2.
dmath.dpow(base, exp)Deterministic exponentiation.

Client API

playLocal

Start a local match (no network). Import from @pupkins/create/client.

import { playLocal } from '@pupkins/create/client';
import logic from '../logic/game.js';

const match = playLocal({
  logic,
  seats: ['me', 'cpu'],   // 'me', 'cpu', or a function
  input: (keys) => { /* return input integer from keyboard state */ },
  names: ['Player 1', 'CPU'],
  riders: ['moki', 'schiffy'],
});

playOnline

Join an online match through the platform. Quick match or room code.

import { playOnline } from '@pupkins/create/client';
import logic from '../logic/game.js';

// Quick match
const match = playOnline({
  logic,
  game: 'my-game',
  quick: true,
  input: (keys) => { /* ... */ },
  rider: 'moki',
});

// Room code
const match = playOnline({
  logic,
  game: 'my-game',
  code: 'ABCD',
  input: (keys) => { /* ... */ },
});

Match object

Property / MethodDescription
match.stateThe current game state.
match.on('end', fn)Fires when the match ends.
match.on('room', fn)Fires when the room is joined.
match.on('start', fn)Fires when the match starts.
match.on('go', fn)Fires on each tick (rollback) or step (turns).
match.on('status', fn)Fires on status changes (waiting, playing, etc.).
match.on('closed', fn)Fires when the room closes.
match.ready()Signal that the client is ready to start.
match.rematch()Request a rematch in the same room.

Picks

Players can pick a value before the match starts (e.g. character selection).

const match = playOnline({
  logic,
  game: 'my-game',
  quick: true,
  pick: 'moki',             // initial pick
  input: (keys) => { /* ... */ },
});

match.pick('schiffy');       // change pick before match starts
match.seats[0].pick;         // read another player's pick

Preloading

Use the load callback to preload assets before the match starts.

const match = playOnline({
  logic,
  game: 'my-game',
  quick: true,
  input: (keys) => { /* ... */ },
  load: async (match) => {
    // load 3D models, textures, sounds
    // the platform shows a loading card until this resolves
  },
});

Hub Integration

When your game runs on the platform, it is inside a hub frame. Import hub from @pupkins/create/client.

APIDescription
hub.inHubtrue when running inside the platform's frame.
hub.player()Returns { name, rider } for the signed-in player.
hub.ready()Dismiss the platform's loading card once the game is ready.
hub.invite({ room: code })Open the platform's invite sheet with a room code.
hub.invite({ world: name })Open the invite sheet for a named world.
hub.on('pause', fn)Fires when the player backgrounds the game.
hub.on('resume', fn)Fires when the player returns.
hub.fullscreen()Request fullscreen.
hub.exit()Leave the game and return to the platform.

Hub rules

  • No alert(), confirm(), or prompt().
  • No form submits.
  • No new windows or tabs.
  • The frame runs only the game's own files.

Scenes

Scenes are 3D levels or 2D boards stored as scenes/*.scene.json files. They are edited visually in the browser and loaded at runtime.

Loading a scene

import { loadScene } from '@pupkins/create/scene';

const world = await loadScene('arena');

Scene editor

Open the editor at /edit on the dev server, or run npm run edit. Place props from the library, set spawn points, and name objects for logic lookup. Ctrl+S saves; the game page reloads with the new layout.

World API

MethodDescription
world.spawn('gem')Create a new object from a prefab.
world.get(id)Get a named object by its id.
world.all(tag)Get all objects with a given tag.

Logic names

Set "logic": "spawn" on scene objects to give them a name that logic/scene.js can reference. Coordinates are whole numbers.

Scene CLI

pupkins-create scene sync          # sync scene files after manual edits
pupkins-create scene new arena      # create a new scene file

Scenes support prefabs for repeated objects, scatter for placing many copies at once, and shared materials for consistent styling.

Game Modes

Rollback (default)

Every screen runs the logic and predicts ahead. Corrections are invisible at normal latency. Standard for real-time games with a few players.

Inputs are small integers, up to input.bits bits wide. The cpu field in input hands empty seats to your bot function.

export default defineLogic({
  id: 'my-game', version: 1, seats: 2, tickHz: 60,
  input: { bits: 3, cpu: 4 },
  setup(match) { return { tick: 0, rng: match.seed | 0 }; },
  step(state, inputs) {
    // inputs[0] and inputs[1] are small integers
    state.tick++;
  },
  result(state, why) { return null; },
  bot(state, seat) { return 0; },
});

Lockstep

Set mode: 'lockstep' for big games with armies. The game steps once when every player's input is in. Input is a list of up to input.length whole numbers (orders).

inputs[seat] is null when nobody plays that seat. Reconnecting players catch up by replaying — skip sounds while match.catching is true.

export default defineLogic({
  mode: 'lockstep',
  id: 'my-game', version: 1, seats: 4, tickHz: 10,
  input: { length: 20 },
  setup(match) { return { turn: 0 }; },
  step(state, inputs) {
    // inputs[seat] is an array of orders, or null
    for (let s = 0; s < 4; s++) {
      if (inputs[s]) { /* process orders */ }
    }
    state.turn++;
  },
  result(state, why) { return null; },
});

Turns

Set mode: 'turns'. Only the room runs the logic. Each screen sees a filtered view — no secrets leak.

step(state, actions) receives [{ seat, action }]. view(state, seat) builds what each screen sees. Views must be under 64 KB. bot(view, seat) returns an action. On the page, players use match.act(action).

export default defineLogic({
  mode: 'turns',
  id: 'my-game', version: 1, seats: 2,
  setup(match) { return { hands: [[], []], deck: [] }; },
  step(state, actions) {
    // actions is [{ seat, action }]
    for (const { seat, action } of actions) {
      // process each player's action
    }
  },
  view(state, seat) {
    // return what this seat can see (no other player's secrets)
    return { hand: state.hands[seat], deckSize: state.deck.length };
  },
  result(state, why) { return null; },
  bot(view, seat) { return { card: 0 }; },
});

Presence / Worlds

Set mode: 'presence'. No rules, no logic. Each screen publishes its player's position and the platform syncs everyone.

The presence field defines published fields with { min, max, step }. Additional fields: spawn, maxSpeed, capacity, emotes.

export default defineLogic({
  mode: 'presence',
  id: 'my-world', version: 1,
  capacity: 24,
  spawn: { x: 0, y: 0, z: 0 },
  maxSpeed: 5,
  emotes: ['wave', 'dance', 'sit'],
  presence: {
    x: { min: -100, max: 100, step: 0.1 },
    y: { min: 0, max: 50, step: 0.1 },
    z: { min: -100, max: 100, step: 0.1 },
    facing: { min: 0, max: 360, step: 1 },
  },
});

On the page:

import { joinWorld } from '@pupkins/create/client';

const world = joinWorld({ logic, game: 'my-world', world: 'plaza' });

// publish position every frame
world.publish({ x: 10.5, y: 0, z: -3.2, facing: 90 });

// read other players
const peers = world.peers();

// emote and teleport
world.emote('wave');
world.teleport({ x: 0, y: 0, z: 0 });

Characters & Avatars

Loading an avatar

Import loadAvatar from @pupkins/create/avatars. It builds a rigged, animated character from the platform's character catalog.

import { loadAvatar } from '@pupkins/create/avatars';

const avatar = await loadAvatar('moki');         // generic Moki
const avatar = await loadAvatar('schiffy');      // generic Schiffy
const avatar = await loadAvatar('moki#1234');    // a specific owned Moki (by rider key)

Actions pack

Pass clips: ['actions'] to load 25 action clips: Sit, Sleep, Hurt, KO, Attack, Swipe, Punch, Slam, Throw, Block, Dash, Dig, Chop, Craft, Tinker, Swim, No, Angry, Alert, Tired, Worship, Idle2, Idle3, and more.

const avatar = await loadAvatar('moki', { clips: ['actions'] });

avatar.play('Sit');
avatar.play('Sit', { reverse: true });

Custom animations

Create a custom animation with the CLI, edit it at /anim on the dev server, and load it alongside the character.

pupkins-create anim new Backflip        # scaffold a custom animation
npm run anim                             # open the animation editor
const avatar = await loadAvatar('moki', {
  clips: ['./anims/{rig}.glb'],          // loads your custom clip
});

Deferred loading

Set deferred: true for lazy-loading. A placeholder capsule is shown until the full model finishes loading. Good for faster first frame on phones.

const avatar = await loadAvatar('moki', { deferred: true });

2D portraits

Import FREE_RIDERS from @pupkins/create/avatars for 2D portrait images of the platform's free characters.

Props

Over 300 3D props from the platform's games are available: barrels, trees, stadiums, buildings, and more. Props load from the platform's asset catalog, not from the game folder.

Searching props

pupkins-create props barrel             # search by keyword
pupkins-create props tree --kind prop   # filter by kind: prop, set, world, fx
pupkins-create props village --pictures # show preview images
pupkins-create library                  # browse the full catalog in 3D (/library on dev server)

In scenes

In a scene file, place a prop by its catalog name:

{ "type": "prop", "prop": "village-barrel" }

In code

import { loadProp } from '@pupkins/create/avatars';

const barrel = await loadProp('village-barrel');
const crate = await loadProp('village-crate', { height: 0.5 });  // size it

Publishing & Builds

Publishing

pupkins-create publish sends the game to the build service. Dot files and node_modules are excluded. Limits: up to 50 MB packed, 150 MB files.

Build process

The build runs in an isolated container: npm ci, npm run build, and all checks. Under a minute typically. One build at a time, 30 a day, 14-minute timeout.

Each passing build gets a preview address. The SDK is replaced with the platform's verified copy. Content is scanned for prohibited patterns (eval, remote scripts, wallet access, service workers). Every output file gets SHA-256 integrity hashing.

Releasing

CommandWhat it does
pupkins-create releaseMakes the last passing build live.
pupkins-create release <hash>Roll back to a specific build.
pupkins-create release --offTake the game down. Builds are kept.

Logs & Debugging

pupkins-create logs                     # last 7 days
pupkins-create logs --days 30           # last 30 days
pupkins-create logs --build <hash>      # one build only

What the logs show

CategoryDescription
FaultsLogic threw an error, timed out (1 second limit), or state exceeded 4 MB. Each ended its match.
Room errorsThe game couldn't start in a room.
Players turned awayRate-limited or game resting.
PlaysPer build per day: matches and outcomes, player/CPU seats, time played.

MCP Server

The kit includes an MCP server that lets Claude (or any MCP client) look up docs, props, builds, and logs as it works.

ToolWhat it does
room_logs, playsFaults, room errors, plays per build and day.
game_builds, my_gamesBuilds, releases, addresses; your games list.
limitsBuilds today, upload limits, daily budget.
dev_previewThe game's dev preview URL and last sync time (remote dev mode).
dev_statusWhether remote dev is enabled, the URL, last sync time, and any errors.
docs_searchThis reference, CLAUDE.md, the skill.
catalog_searchCharacters, clips, emotes, props, starters.

Determinism Rules

Every screen and the room run your logic identically. These rules keep it that way.

The 7 rules

  1. Whole numbers only. Use fixed for math. Floats differ across engines.
  2. No hidden inputs. No Math.random(), no Date, no timers. Randomness comes from fixed.rand(state).
  3. No transcendental Math. No Math.sqrt, Math.sin, Math.cos, etc. Use fixed equivalents or dmath.
  4. Plain data in fixed order. No for...in, no sort() without a comparator, no Maps or Sets in state.
  5. Imports: only @pupkins/create/logic and logic/ files. No other modules in the logic folder.
  6. Inputs are small integers. The network sends only what input defines.
  7. Bump version when rules change. Rooms reject mismatched versions.

Never

  • No wallets or web3.
  • No eval, new Function, or inline scripts.
  • No service workers.
  • No secrets, keys, or tokens in files.
  • No personal data collection.

Phone Optimization

Many players will be on phones. The SDK provides helpers to adapt.

APIDescription
isPhone()Returns true on phone-sized screens. Import from @pupkins/create/client.
deviceTier()Returns 'high', 'medium', or 'low' based on the device's GPU and CPU.

Tips

  • Use deferred: true in loadAvatar for faster first frame.
  • Skip heavy post-processing effects on low-tier devices.
  • Reduce draw calls and texture sizes when deviceTier() returns 'low'.
  • Test on a real phone — the dev preview URL or pupkins-create share gives you a link to open on any device.
import { isPhone, deviceTier } from '@pupkins/create/client';

if (isPhone()) {
  // use simpler UI layout
}

if (deviceTier() === 'low') {
  // skip shadows, reduce particles
}