# GrowBlocks Dev Kit — reference for AI coding assistants You are writing a game for GrowBlocks, a browser game platform. The game is plain JavaScript (ES modules). It runs on GrowBlocks' engine: three.js 0.170, Rapier 0.14 physics, and GrowBlocks characters. This file is the complete API. Use only what it documents. Do not invent functions. ## The contract - The game is a folder with `game.js` at its top level. `game.js` must `export default async function game(gb){ ... }`. - `gb` is created for you before your function runs. Do NOT create a renderer, scene, camera, canvas or animation loop: `gb.renderer`, `gb.scene` and `gb.camera` already exist, and the loop is running. Use `gb.onUpdate(fn)` for per-frame logic. - You may `import * as THREE from 'three'` and `import {X} from 'three/addons/...js'`. These are the same instances gb uses. - You may import your own files with relative paths: `import {level} from './level.js'`. - No npm packages, no bundler, no TypeScript compilation, no Node APIs. - It runs in a sandbox with no origin: - `localStorage`, `sessionStorage`, `indexedDB` and `document.cookie` THROW or are empty. Use `await gb.store.get/set`. - `fetch` works only for your own build files (use `gb.assets.url(path)`) and cdn.jsdelivr.net. Other servers are blocked. - No popups, no forms, no `alert()`/`prompt()`/`confirm()`. Use `gb.ui.toast` and your own DOM in `gb.ui.root`. - It cannot touch wallets. All chain actions go through `gb.chain`, which opens GrowBlocks screens. - Units: metres and seconds. +Y is up. The ground is y=0. A GrowBlocks character is 1.9 m tall. - Yaw: 0 looks toward −Z. Forward = (−sin yaw, 0, −cos yaw). Positions are `[x,y,z]` arrays or `{x,y,z}` objects. - Gravity is 20 m/s² (snappy, arcade feel). The physics step is fixed at 60 Hz. `dt` passed to `onUpdate` is in seconds (max 0.1). ## Minimal game ```js export default async function game(gb){ gb.world.style('day'); // day | sunset | night | neon | space gb.world.ground({size:60}); // a floor with physics gb.world.box({size:[2,1,2], position:[0,0.5,-6], color:'#ff5fa2'}); // a fixed block const ball=gb.world.sphere({radius:0.5, position:[0,4,-6], color:'#ffd23a'}); // dynamic by default gb.player.spawn({position:[0,1,6]}); // WASD/stick, Shift run, Space jump, mouse/drag looks let score=0; gb.onUpdate((dt,t)=>{ if(gb.player.position.distanceTo(ball.position)<1.2){ score++; gb.sound.play('coin'); gb.physics.place(ball,[Math.random()*10-5,4,-6]); } gb.ui.text('score','Score: '+score,'tl'); }); } ``` ## API ### gb (top level) - `gb.THREE`, `gb.RAPIER`: the libraries. - `gb.scene`, `gb.camera` (PerspectiveCamera, fov 62), `gb.renderer`, `gb.sun` (DirectionalLight with shadows), `gb.hemi` (HemisphereLight). - `gb.onUpdate((dt,t)=>{})`: runs every frame after physics. Returns an unsubscribe function. An update that throws is removed and reported. - `gb.material(color, {glow=0, metal=0, rough=0.8})`: a `MeshStandardMaterial` in the GrowBlocks look. - `gb.me`: `{name, look, signedIn, address}`. `address` is always null here: a game learns the wallet address only from `gb.chain.balance()`, after the player agrees (and never in a test build). - `gb.game`: `{slug, name, mode}`. `mode` is `'live'`, `'test'` or `'dev'` (a local build). - `gb.VERSION`: the SDK version, `'1.6.0'`. - `gb.onUpdate((dt,t,real)=>{})`: `dt` is on the game clock (slowed by `gb.time`), `real` is the real frame time. ### gb.world All of these add to `gb.scene` and return the `THREE.Mesh`. - `gb.world.style(name)`: sets sky, fog and lights. - `gb.world.ground({size=80, color, y=0})`: a fixed floor. - `gb.world.box({size=[1,1,1], position=[0,0.5,0], rotation=[0,0,0], color='#fff', glow=0, physics='fixed', mass=1, bounce=0})` - `physics` is `'fixed'`, `'dynamic'`, `'kinematic'` or `false`. - Important: `position` is the CENTRE, so a 1 m box on the ground sits at y=0.5. - `gb.world.sphere({radius=0.5, position=[0,1,0], color, glow, physics='dynamic', mass=1, bounce=0.3})`. ### gb.physics - `gb.physics.add(mesh, {type='dynamic', shape, mass=1, bounce=0, friction=0.7, sensor=false})` - Gives any mesh you made a body. `type` is `'dynamic'`, `'fixed'` or `'kinematic'`. `shape` is `'box'`, `'sphere'` or `'capsule'`; it is guessed from the geometry if omitted. - Add the mesh to the scene and position it BEFORE calling this. - Returns the `RAPIER.RigidBody`. - Dynamic meshes are moved by the simulation. Do not set their `position` directly; use `place`, `push` or `velocity`. - Kinematic meshes follow `mesh.position`, which you set each frame (moving platforms, doors). - `gb.physics.push(mesh,[x,y,z])`: an impulse. `gb.physics.velocity(mesh,[x,y,z])`: sets the velocity. - `gb.physics.place(mesh,position)`: teleports a body (and zeroes its velocity). - `gb.physics.remove(mesh)`: removes the body, not the mesh. Also call `gb.scene.remove(mesh)` if you want it gone. - `gb.physics.onHit(mesh, other=>{})`: called when `mesh` starts touching another body. `other` is the other mesh, or null when it is the player's capsule or a body you created by hand. Returns an unsubscribe function. - `gb.physics.raycast(from, dir, max=100)`: returns `{mesh, point, distance}` or null. It ignores the player. - `gb.physics.gravity`: a number (20). Set it to change gravity. - `gb.physics.body(mesh)`: the Rapier body. `gb.physics.world`: the raw Rapier world. ### gb.player - `gb.player.spawn({position=[0,2,0], camera='third', name, look, character=null})` - `camera` is `'third'`, `'first'` or `false`. With `false` you drive `gb.camera` yourself, from `onUpdate`. - `character` is a model from `gb.assets.character()`. Without it you get the GrowBlocks character. - Calling spawn again replaces the character (for example, to change it). - Read: `gb.player.position` (THREE.Vector3, at the feet), `gb.player.velocity`, `gb.player.yaw`, `gb.player.pitch`, `gb.player.grounded`, `gb.player.height`. - Tune: `gb.player.speed=6`, `gb.player.sprint=9`, `gb.player.jump=8` (jump velocity), `gb.player.distance` (third-person camera distance). - `gb.player.enabled=false`: freezes movement and look (menus, cut-scenes). - `gb.player.teleport(position)`: moves it instantly. Falling below y=−50 respawns at [0,3,0]. - `gb.player.avatar`: its character handle. For example, `gb.player.avatar.say('Hi!')` shows a speech bubble. - Dynamic bodies the player walks into are pushed. ### gb.avatar(name, look={}, {character=null}) Another character: an NPC, or a remote player you draw yourself. It returns a handle with: - `.moveTo(x,y,z,yaw,grounded=true)`: it walks there smoothly and animates by itself. - `.place(x,y,z,yaw)`: it snaps there. - `.say(text)`: a speech bubble. - `.remove()` - `.group`: its THREE.Group. Yaw for characters: a character faces the direction (sin yaw, 0, cos yaw). To face a direction vector d, use `yaw=Math.atan2(d.x,d.z)`. ### Custom characters: gb.assets.character(url, options) ```js const hero = await gb.assets.character('models/hero.glb', {height:1.8}); // scale it to 1.8 m tall gb.player.spawn({character: hero}); const guard = gb.avatar('Guard', {}, {character: hero}); ``` - Options: - `height` (metres, scales the model) - `scale` (used when `height` is not given) - `facing` (radians to turn a model that does not face +Z; try `Math.PI`) - `clips:{idle,walk,run,jump}` (exact clip names; guessed from names containing idle/walk/run/jump when omitted) - `runAt=7` (the speed above which the run clip plays) - The model must be a rigged GLB/GLTF with animation clips. It is loaded once and cloned for every character that uses it. - If a clip name is wrong, the error lists the clips the file actually has. - Free rigged models: Mixamo (export as GLB via Blender), Quaternius, Kenney. - A test model that always works: `https://cdn.jsdelivr.net/gh/mrdoob/three.js@r170/examples/models/gltf/RobotExpressive/RobotExpressive.glb` (CC0). - Ship your own models inside your build folder and load them by relative path. ### gb.input - `gb.input.held(code)`: whether the key is down now. `code` is a KeyboardEvent.code such as `'KeyE'`, `'Space'`, `'ArrowUp'` or `'ShiftLeft'`. - `gb.input.pressed(code)`: true only on the frame the key went down. `'Mouse0'` is a left click and `'Mouse2'` a right click. - `gb.input.move`: `{x,y}` in −1..1, from WASD, the arrows or the touch stick. y>0 means forward. - `gb.input.action`: true while fire/act is held (left mouse, or the ACT touch button). - `gb.input.mouse`: `{dx,dy,buttons,wheel}` for this frame. - `gb.input.touch`: true on phones. Phones get a stick, a JUMP button and an ACT button automatically. - `gb.input.lockOnClick=true`: a click locks the mouse so looking works. Set it to false for a menu or cursor game. - `gb.input.pads`: the game controllers plugged in: `[{index, id, move:{x,y}, look:{x,y}, held(b), pressed(b), value(b)}]`. Buttons by name: `'A' 'B' 'X' 'Y' 'LB' 'RB' 'LT' 'RT' 'Back' 'Start' 'LS' 'RS' 'Up' 'Down' 'Left' 'Right'`. `move` is the left stick or d-pad, y>0 forward. - `gb.input.rumble(strength=0.6, ms=120, pad=-1)`: a buzz in every pad (or one) and, on a phone, the phone. - `gb.input.controls({actions, split, players})`: named actions for 1-4 players on one screen (see Sports games below). - `gb.input.touchControls({buttons, edge, pause})`: phone buttons that say what they do (see Sports games below). ### gb.ui (DOM overlay, above the canvas) GrowBlocks shows a "Built with GrowBlocks" badge in the bottom-right corner of every game (about 190 x 40 px, drawn by GrowBlocks outside your game). Keep your own HUD out of that corner. - `gb.ui.toast(text, ms=2600)`: a message at the top. - `gb.ui.text(id, html, where='tl')`: creates or updates a block, placed `'tl'`, `'tr'`, `'bl'`, `'br'` (above the GrowBlocks badge), `'top'` or `'center'`. Returns the element. `html` is trusted HTML: escape anything that came from players. - `gb.ui.clear(id)`. - `gb.ui.root`: the overlay element, for your own DOM. It has `pointer-events:none`; set `pointerEvents='auto'` on your buttons. - `gb.ui.canvas()` → `{canvas, ctx, width, height}`: a full-screen 2D canvas over the game (under `gb.ui.text`), for a drawn HUD: scoreboards, callouts, name tags over heads. `ctx` draws in CSS pixels and follows the window; clear it yourself every frame. - `await gb.ui.fonts()` → `{display, text}`: loads the GrowBlocks typefaces (chunky Lilita One, and Nunito) and gives their font-family strings for CSS and `ctx.font`. Await it before painting text onto canvas textures. - `await gb.ui.share({text, url})` → `{ok, via, url}`: share buttons ("Post on X", "Share to Telegram") must call this. A game runs sandboxed and cannot open tabs, so an `` or `window.open` does nothing; GrowBlocks shows its own sheet with X, Telegram and Copy. `text` is up to 240 characters (put scores and challenges here, not in URL parameters). Leave out `url` for the game's GrowBlocks share card; any other `url` must be a path on growblocks.xyz. `via` is `'x'`, `'telegram'`, `'copy'` or `null` (closed). In a local build it only logs what would be shared. ### gb.sound - `gb.sound.play(kind='coin', volume=0.3, {rate=1})`: a clip you loaded by name, or a synthesized one: `'coin'`, `'jump'`, `'hit'`, `'win'`, `'click'`. - `await gb.sound.load({swish:'sfx/swish.mp3', bounce:['sfx/b1.mp3','sfx/b2.mp3']})`: decode your own clips once (a list plays one at random each time, so repeats never sound the same). Files must be in your build (mp3, ogg, wav). - `gb.sound.loop(name,{volume})` → `{stop(), volume(v)}`: crowd noise, engines, a fire crackle. - `await gb.sound.music('music/theme.mp3',{volume:0.5, loop:[start,end]})`: looped music (optional loop points in seconds). `gb.sound.music(false)` stops it. - `gb.sound.duck(true|false)`: music down under a big moment, and back. - `gb.sound.say(name,{volume, gap=1.6, force})`: an announcer line: one at a time, at most every `gap` seconds unless `force`. Returns whether it played. - `gb.sound.musicOn` (the player's music switch), `gb.sound.muted` (everything), `gb.sound.ready` (audio has started: browsers wait for the first touch or key; GrowBlocks wakes it for you). ### gb.assets All of these take paths relative to your build folder. - `gb.assets.url(path)`: the full URL of a file in your build. - `await gb.assets.gltf(path)`: a GLTF (`{scene, animations}`). Add `gltf.scene` to `gb.scene` yourself. - `await gb.assets.texture(path)`: a THREE.Texture in sRGB. - `await gb.assets.json(path)`. - `await gb.assets.character(path, options)`: see above. - Load each file from the fixed path it ships at. A load rejects when the file is missing, so wrap an optional asset in `try/catch` and fall back; do not probe first. Published, your files are served like a static site (GET and HEAD). `node check.mjs --play` lists every file the game asked for and did not get: fix each one, because the published game would miss it too. ### gb.store (saved progress on the player's device, per game, up to 64 KB) - `await gb.store.get(key)` returns the value or null. `await gb.store.set(key, value)`. - Values are JSON-serialisable. ### gb.net (multiplayer rooms on the GrowBlocks match server) ```js const room = await gb.net.join(); // or gb.net.join({room:'red', max:4}) room.showPlayers(); // draw everybody's character, send yours 10x/s room.on('join', p=>{}); room.on('leave', p=>{}); room.on('message', (data, fromId)=>{}); // data: any JSON, up to 8 KB room.send(data); room.sendTo(id, data); // about 40 messages a second per player room.state.set(key, value); room.state.get(key); room.state.delete(key); room.state.keys(); room.on('state', (key, value, byId)=>{}); // shared state: 256 keys, 64 KB; late joiners get it on join room.id; room.players /* Map id->{id,name} */; room.isHost; room.host; room.on('host', id=>{}); room.leave(); room.on('close', ()=>{}); room.showPlayers({character: hero}); // everybody drawn as your model room.showPlayers({characters:{knight, robot}, pick:'knight'}); // each player picks; room.pick('robot') changes it ``` - Rooms fill up to the game's players-per-room setting (at most 16), then a new room opens. - The server relays messages; it does not simulate or validate them. Pattern: the host (`room.isHost`) decides shared outcomes (spawning, scores, round timers) and broadcasts them; everyone else renders. Hosting moves automatically when the host leaves. - `room.state` survives until the room empties. Use keys like `'score:'+room.id` for per-player values. - `gb.net.join()` rejects when offline or when multiplayer is unavailable. Catch it and let the game run solo. ### gb.chain (money and NFTs, through GrowBlocks' own screens) | | | |---|---| | `await gb.chain.balance()` | `{signedIn, shared, address, tPLAY, ETH}` (strings). In an approved game the first call asks the player, on a GrowBlocks sheet, whether to share their wallet with this game; if they say no you get `{signedIn:true, shared:false}` and should carry on without it. In a test build (before approval): address null, balances "0". | | `await gb.chain.collections()` | `[{collection, name, symbol, kind, live}]`: this game's NFT collections | | `await gb.chain.items()` | `[{collection, tokenId, itemName, ...}]`: pieces this player owns from them | | `await gb.chain.earn(itemKey, {rule})` | the player earned an item (first boss kill, a rare find). GrowBlocks checks `rule` against your build's `rewards.json`, mints the design `itemKey` of your earned collection to the player's game wallet and pays the gas. No screen during play: the player allows it once per game. Resolves `{ok, status}`: `minted`, `pending` (mints later: no wallet yet or no gas), `already` (this moment was earned before), `denied`, `declined`, `signin`, `simulated` (local builds, and other players on your test link). On your test link you, the developer, earn real testnet items from that build's `rewards.json` (up to 20 a day), so you can test the whole loop before review | | `await gb.chain.mint(collection, {qty:1})` | opens the GrowBlocks mint screen; resolves `{ok, minted:[{collection,tokenId}]}` | | `await gb.chain.market()` | opens the GrowBlocks shop for this game's pieces (buy, sell, list); resolves when closed | | `await gb.chain.trade(address, {name})` | opens the GrowBlocks trade window with another player of this game: both pick pieces and/or coins and sign one swap. Get `address` from the other player: they call `gb.chain.balance()` (which asks them first) and your game sends `address` over `gb.net`. Never ask players to type addresses. No address opens the Trade tab (offers waiting, trade code). Error code `'trade-invalid'` for a malformed or own address | | `await gb.chain.coin()` | opens this game's coin trading screen | | `await gb.chain.token()` | `{name, symbol, status, address, tradable}`: this game's coin. `status` is `'none'` (not chosen), `'held'` (name and ticker chosen by the developer in the Dev Kit, not launched yet), `'launching'` or `'live'`. Public, no sheet | | `await gb.chain.tokenBalance()` | `{signedIn, shared, symbol, amount}`: how much of this game's coin the player holds (`amount` a decimal string). Asks once, like `balance()` | | `await gb.chain.holds({collection, minimum:1})` | `true` when the player owns at least `minimum` pieces of this game's collections (or of one `collection`). Use it to unlock skins, levels or perks | | `gb.chain.onChange(fn)` | `fn({op})` after a GrowBlocks mint, market or coin screen closes (`op` is `'mint'`, `'market'`, `'trade'` or `'coin'`): refresh what the player owns. Returns a function that stops it | - One GrowBlocks screen at a time: `mint`, `market`, `coin` (and the share question) reject with code `'busy'` while one is open and for 3 seconds after it closes. Call them from a player's action, never in a loop. - Errors carry `err.code`: - `'not-live'`: a test build cannot use the chain. - `'signin'`: the player did not sign in. - `'wallet'`: the player has no game wallet. - Always catch these and show a message. - In a local build (`gb.game.mode==='dev'`) every chain call is simulated, so you can develop the flow. - A game cannot send transactions itself. Rewards that should be NFTs are minted by the player from the game's collections (the developer creates them in GrowBlocks). - The game's coin and collections belong to the game: they are created from the game's plaza by its developer, so they trade in the GrowBlocks market, the Games Plaza (at the game's gate) and the launchpad, and the game reads them with `token()`, `items()` and `holds()`. A game never names or launches a coin itself and never takes a token address from anywhere else: the developer launches it on GrowBlocks (main plaza, LAUNCHPAD, Create a coin, Launch for plaza: the game), and the game only reads it with `token()`. ### gb.ai (AI decisions: Sage by Levanto Labs, SDK 1.2) Typed decisions for map generation, enemy and combat AI, NPC behaviour, difficulty and moderation. GrowBlocks calls Sage for your game with its own key: never put an API key in game code, and never call sage.levanto.ai (or any AI API) directly, the sandbox blocks it. Content may be text or any JSON value (a game state, a map description); objects are sent as JSON. | | | |---|---| | `await gb.ai.yesno(content, instructions)` | `{answer:'yes'|'no'|null, probability}` | | `await gb.ai.choice(content, instructions, options)` | `{chosen, probability, probabilities}`. `options`: 2-120 strings or `{option, description}` (option ≤64 chars) | | `await gb.ai.scale(content, instructions, levels)` | `{expectation, confidence}`, expectation 0-4. `levels`: exactly five strings describing 0..4 | | `await gb.ai.sort(items, instructions)` | `{sorted, confidence}`: item ids, first = most. `items`: 2-120 strings (ids `i0`, `i1`, ...) or `{id, content}` | | `await gb.ai.tags(content, tags, instructions)` | `{tags:[{id, probability, applies}]}`. `tags`: 1-120 strings like `"flank: move around the side"` (id = text before the colon) or `{id, name}` | | `await gb.ai.decide(content, question)` | Sage's full response for one question object `{kind, instructions, options|levels|tags}`. With an ARRAY of questions: one batch call, returns `[{ok, result}|{ok:false, error}]` in order | | `await gb.ai.decideGroups([{content, questions:[...]}, ...])` | many contents in one call (≤16 groups, ≤40 questions): `{results:[{answers:[...]}], meta}` | - Every call takes a last optional `{reasoning:'auto'|'off'|'on'}`. `'off'` answers fastest (about 0.1-0.3 s); `'auto'` (default) thinks only when needed (up to ~6 s); `'on'` always thinks. - `null` (`answer`, `chosen`, `applies`) means Sage is not sure: use your own default rule, do not retry. - Calls take a network round trip: NEVER call gb.ai every frame. Decide per turn, per wave, per enemy spawn or every 1-2 seconds, keep the last decision and act on it in `onUpdate`. Ask several questions in one `decide(content, [..])` or `decideGroups` call. - Each game has a monthly allowance of decision units (one per question; long content counts more). Errors carry `err.code`: `'ai-allowance'` (used up this month), `'ai-rate'` (too many a minute), `'ai-unavailable'`, `'ai-invalid'` (bad request), `'ai-dev-key'` (local build without a dev key). Always catch and fall back to plain game logic. - Local builds (play.html) call the real Sage with the developer's dev key: make one on growblocks.xyz/devkit.html and save it as `growblocks.key` next to play.html (never commit it, never upload it). - Limits: content ≤ 20,000 characters, instructions ≤ 2,000. No images or web search. Enemy AI, decided every 1.5 s and acted on every frame: ```js let plan='chase', thinking=false; async function think(){ if(thinking) return; thinking=true; try{ const r=await gb.ai.choice({me:{hp:enemyHp, pos:enemy.group.position.toArray()}, player:{hp:playerHp, pos:gb.player.position.toArray()}, allies:alive}, 'You are the enemy. What should you do next?', [ {option:'chase', description:'close the distance and attack'}, {option:'flank', description:'circle around the player'}, {option:'retreat', description:'back off to heal when hurt'}], {reasoning:'off'}); plan=r.chosen ?? plan; // not sure: keep the last plan }catch(e){ plan='chase'; } // no AI (allowance, network): plain rule finally{ thinking=false; } } setInterval(think,1500); gb.onUpdate(dt=>{ /* move the enemy according to plan */ }); ``` Map generation, one batch before the level starts: ```js const rooms=[{id:'r1',content:'large hall, 3 exits'},{id:'r2',content:'narrow corridor, dead end'},{id:'r3',content:'treasure vault, 1 exit'}]; const [danger, loot]=await gb.ai.decide({theme:'crypt', depth:3, rooms}, [ {kind:'scale', instructions:'How dangerous should this floor be?', levels:['trivial','easy','fair','hard','deadly']}, {kind:'tags', instructions:'Which features belong on this floor?', tags:['traps: hidden floor traps','boss: a boss room','shop: a merchant']} ]); const order=await gb.ai.sort(rooms, 'Order rooms from entrance to goal for a good difficulty curve.'); ``` ## Sports games and arcade feel (SDK 1.3) GrowBlocks' own SLAM BLOCKS (2v2 arcade basketball) is built with exactly these; its full source is the example at https://growblocks.xyz/devkit/examples/slam/ (game.js, court.js, rules.mjs). Read it before building a sports game. ### gb.render (post effects) - `await gb.render.bloom({strength=0.55, radius=0.45, threshold=0.82})`: glow on everything bright (emissive materials, `toneMapped:false` lights, fire). `gb.render.bloom(false)` turns it off. - `await gb.render.add(pass)` / `gb.render.remove(pass)`: your own three.js pass from `three/addons/postprocessing/`, drawn before bloom. - `gb.render.pixelRatio(max=2)`: sharpness against speed (1.5 on phones is a good trade). - Floor reflections: `import {Reflector} from 'three/addons/objects/Reflector.js'`, a reflector just under a slightly transparent floor (skip it on phones). ### gb.time (slow motion, hit-stop) - `gb.time.slow(scale, hold=0)`: the game clock eases towards `scale` (0.3 = a monster-dunk slow-mo) and stays there `hold` seconds. With no hold it lasts this frame, so call it every frame while the moment lasts. - `gb.time.hitstop(seconds)`: the clock nearly stops for a beat (0.07-0.14 s on a big hit). It sells impact. - `gb.time.scale`: the clock's speed now. `gb.time.real`: the last real frame time. - The `dt` given to `onUpdate` and the physics run on this clock; draw HUDs and move cameras with the third argument (`real`) so they never slow down. ### gb.input.controls (1-4 players on one screen) ```js const ctl = gb.input.controls({ actions: { shoot: {keys:['Space','KeyJ'], pad:['A'], touch:'shoot'}, pass: {keys:['KeyK'], pad:['X','B'], touch:'pass'}, turbo: {keys:['ShiftLeft','ShiftRight'], pad:['RT','LT'], touch:'edge'} // 'edge' = phone stick pushed to its edge }, split: { // optional: two players, one keyboard, no pads left: {move:'wasd', shoot:['KeyF'], pass:['KeyG'], turbo:['ShiftLeft']}, right: {move:'arrows', shoot:['Enter'], pass:['Period'], turbo:['ShiftRight']} } }); ctl.players = 2; // 1-4 const c = ctl.read(0); // {move:{x,y}, held:{shoot,pass,turbo}, pressed:{...}, device} if (c.pressed.shoot) jump(); if (c.held.turbo) sprint(); ctl.devices(); // ['keyboard + pad 1','pad 2'] for your lobby ``` - One player: keyboard, the first pad and the phone buttons all work at once. More: pads in order (player 1 also gets the keyboard); one pad short, player 1 is the keyboard; two players with no pads, the `split` keyboard. - Read each player once per frame inside `onUpdate`. `pressed` is true for one frame. ### gb.input.touchControls (phone buttons that say what they do) ```js const pad = gb.input.touchControls({edge:'TURBO', pause:true, buttons:[{id:'shoot',label:'SHOOT'},{id:'pass',label:'PASS'},{id:'push',label:'PUSH'}]}); gb.onUpdate(()=>{ pad.label('shoot', hasBall ? (nearRim ? 'DUNK' : 'SHOOT') : 'BLOCK', nearRim ? ['#ffb347','#4a2400','#9a5a12'] : null); pad.ring('shoot', jumping ? 1 - vy/6.6 : null, Math.abs(vy) < 1.3); // a timing ring: full and glowing at the top of the jump if (pad.pressed('pause')) openMenu(); }); ``` - Replaces the default stick + JUMP/ACT. The stick appears under the left thumb wherever it lands; pushed to its edge, `pad.edge` is true (the `'edge'` touch action). Up to 4 buttons, biggest first under the right thumb. - `pad.held(id)`, `pad.pressed(id)`, `pad.tapped(id)` (a quick tap was let go: a tap-to-shoot game lets the move go by itself), `pad.label(id, text, [face, text, edge colours])`, `pad.ring(id, k|null, hot)`, `pad.show(false)` for menus. - On a computer it does nothing (`pad.visible` false) unless `force:true`. ### gb.sports (the sports kit) - `const a = gb.sports.athlete({colors:{jersey, trim, shorts, ink}, skin, hair, number, style})`: a voxel athlete in the scene (about 2.1 m with its big head), posed in code. Every frame: `a.update(state, dt)` with `state = {x, y, z, vx, vz, vy, face, speed, anim, hasBall, defending, turbo, celebrate, swipe, shoveT, land, lookYaw, dunk:{s, style}, downT, rising, hangT}`. - `anim`: `'idle' 'run'` (on the floor; a run cycle that matches the ground covered, a low defensive slide when `defending` and moving sideways, a dribble when `hasBall`), `'jump'` (ball set above the head), `'shoot'` (release), `'block'` (arms up), `'dunk'` (`dunk.s` 0..1 through the flight, `style` `'flip' 'double' 'windmill' 'spin' 'tomahawk' 'power'`), `'hang'` (on the rim), `'down'` (knocked flat: `downT` seconds down, then `rising` 0..1 back up). - `celebrate`, `swipe` (a steal), `shoveT` (a shove) count down from 1 to 0; `land` 0..1 on landing (decay it yourself); `lookYaw` turns the head (radians from `face`). - Face is (sin face, 0, cos face). Feet stay on the floor, the body leans with real acceleration and banks into turns: give it honest velocities. - `a.handR` / `a.handL` (put a ball there: `a.handR.getWorldPosition(v)`), `a.dribble` (the dribble clock, 0..1 a bounce), `a.root`, `a.remove()`. - `const fx = gb.sports.effects({auto})`: instanced voxels, nothing allocated while playing: `fx.dust(x,z,n,power)`, `fx.sparks(x,y,z,n,color)`, `fx.burst(x,y,z,n,colors)`, `fx.confetti(n,colors,{x,z,width,depth,height})`, `fx.flame(x,y,z,n,{spread,up,size})` (call every frame for fire), `fx.swipe(x,y,z,face,color)`, `fx.ignite(x,y,z)`, `fx.ring(x,z,color,size)` (a floor shockwave), `fx.shatter(panel, done)` + `fx.rebuild()` (a glass panel explodes into cubes and flies back), `fx.glassBusy`. Updated on the game clock; `{auto:false}` and `fx.update(dt)` to step them yourself (to freeze them in a pause). - `panel = {x, y, z, width, height, side, from:{y,z}, hide:[meshes]}`: a panel standing across X, `width` along Z; `side` is the way its back faces; your own `hide` meshes vanish until rebuilt. - `const crowd = gb.sports.crowd({stands:[{side:'back', distance:10, rows:10, length:36}, {side:'left', distance:17, rows:8, length:16}], shirts:[...]})`: instanced fans on stepped stands facing the middle. `crowd.cheer(level 0..1, holdSeconds)` makes them bounce, `crowd.flash(n)` fires camera flashes (more by themselves when it is loud). Sides: `'back'` is -Z, `'front'` +Z, `'left'` -X, `'right'` +X. - `gb.sports.arc(from, to, seconds, gravity=14)` → `{x,y,z}`: the launch velocity that lands a ball exactly there in that time (shots, passes, kicks; miss by aiming beside the target). `gb.sports.landing(pos, vel, {gravity, y})` → `{x,y,z,t}`: where a flying ball comes down (for AI that chases rebounds). ### Online for sports: the host runs the game, the others draw it ```js const room = await gb.net.join({room:'q1v1-1', max:2}); const stream = room.stream({rate:20, delay:110}); // host, every frame: if (room.isHost) { stepGame(dt); stream.send(snapshot()); } // at most 20 a second go out stream.event(['sfx','swish']); // host: a one-off moment, rides with the next snapshot // a guest, every frame: const s = stream.sample(); if (s) drawBlend(s.a, s.b, s.k); // two snapshots 110 ms back, k 0..1 between them stream.onEvent(e => replay(e)); // events arrive when their time comes room.sendControls(ctl.read(0)); // guest: presses are counted, never lost const c = room.controlsOf(playerId); // host: that player's record, each press once ``` - Keep snapshots small (numbers rounded to 2 decimals, arrays not objects): at most 7.8 KB. - Seat people with `room.state` (the host writes who plays where, then START); when the host leaves (`room.on('host')`) end the game. ### gb.score (leaderboards) - `await gb.score.submit(value, {board:'main', order:'high'})` → `{saved, best, rank}`: kept if it is the player's best on that board. `order:'low'` for times (send it on every submit to that board). `rank` may be null on a big board. - `await gb.score.top({board, limit:10})` → `[{rank, name, value, me}]`. Anybody can read a board. - `await gb.score.mine({board})` → `{best, rank}` or null. - Saving needs a signed-in GrowBlocks player: catch `err.code==='signin'` and show a button that calls `await gb.score.signIn()`, then submit again. - Other codes: `'score-invalid'` (board names are 1-24 of a-z 0-9 -; values finite, up to 1e9), `'score-rate'`, `'score-unavailable'`. - Names on boards are GrowBlocks profile names. Scores come from the game, so boards are for fun: GrowBlocks pays no XP or prizes for them and removes cheats. Local builds and test builds keep separate boards (local ones are simulated, with three made-up players). ## Patterns Moving platform (kinematic): ```js const plat=gb.world.box({size:[3,0.4,3],position:[0,2,0],color:'#53edff',physics:'kinematic'}); gb.onUpdate((dt,t)=>{ plat.position.x=Math.sin(t)*5; }); ``` Pickup with a sensor zone, without physics (just distance): ```js const coin=new gb.THREE.Mesh(new gb.THREE.TorusGeometry(0.4,0.12,8,24), gb.material('#ffd23a',{glow:0.8})); coin.position.set(3,1,0); gb.scene.add(coin); gb.onUpdate(dt=>{ coin.rotation.y+=dt*3; if(coin.visible && gb.player.position.distanceTo(coin.position)<1.2){ coin.visible=false; gb.sound.play('coin'); } }); ``` Shooting with a raycast from the camera: ```js gb.onUpdate(()=>{ if(gb.input.pressed('Mouse0')){ const dir=new gb.THREE.Vector3(); gb.camera.getWorldDirection(dir); const hit=gb.physics.raycast(gb.camera.position, dir, 60); if(hit?.mesh && gb.physics.body(hit.mesh)) gb.physics.push(hit.mesh, [dir.x*4, 2, dir.z*4]); }}); ``` Enemy that chases the player: ```js const enemy=gb.avatar('Zombie',{shirt:'#3a7'}); enemy.place(10,0,-10); gb.onUpdate(dt=>{ const p=gb.player.position, e=enemy.group.position, d=p.clone().sub(e); d.y=0; if(d.length()>1.5){ d.normalize(); enemy.moveTo(e.x+d.x*3*dt*10, 0, e.z+d.z*3*dt*10, Math.atan2(d.x,d.z)); } }); ``` Round timer owned by the host: ```js const room=await gb.net.join(); room.showPlayers(); gb.onUpdate(()=>{ if(room.isHost && !room.state.get('endsAt')) room.state.set('endsAt', Date.now()+90000); }); gb.onUpdate(()=>{ const left=Math.max(0,((room.state.get('endsAt')||0)-Date.now())/1000); gb.ui.text('t',Math.ceil(left)+'s','top'); }); ``` ## Pitfalls - The world is empty until you add a ground: without `gb.world.ground()` or your own floor, the player falls forever (it respawns at y<−50). - Set a mesh's position BEFORE `gb.physics.add`, and never set a dynamic mesh's position directly afterwards. - `gb.world.box` defaults to `physics:'fixed'`, and `gb.world.sphere` to `'dynamic'`. - Do not call `requestAnimationFrame` loops for game logic, do not `new THREE.WebGLRenderer()`, and do not append canvases. - `gb.input.pressed` is only true for one frame. Read it inside `onUpdate`, not in a setTimeout. - Performance budget: aim for under 300 draw calls and 500k triangles, and at most one shadow-casting light (`gb.sun`). Reuse geometries and materials across many meshes, or use `THREE.InstancedMesh` for crowds of identical things. - Everything must work on phones: use `gb.input.move` and `gb.input.action` rather than raw keys for core controls, and keep HUD text short. - Treat other players' messages as untrusted. - Never change `location`, open other pages or load code from other sites: a game that navigates away is stopped, and only the engine files, your own build and the pinned three.js and Rapier are allowed. No `eval`, `new Function` or inline scripts. ## Blocks (reuse other developers' parts) Blocks are parts of games other developers published: vehicles, modes, enemy brains, level kits. Before writing something common, search them: `node growblocks.mjs blocks ` (or the growblocks_blocks tool). `node growblocks.mjs add ` copies it into blocks// and records it in growblocks.json; import it from './blocks//' and read its block.json and code for how to call it. Never edit files under blocks/: GrowBlocks recognises a block by its exact files (its author earns from the game, paid by GrowBlocks; the game keeps its 70%). To change a block, copy it to your own folder, set "forkOf" in its block.json and publish it as a fork. A block exports functions that take gb as a parameter (export function setup(gb, options)), never a global gb. ## Build, test, ship 1. Serve the folder: `npm run dev` (`node growblocks.mjs dev`, a server with CORS on). 2. Open http://localhost:5173/play.html. The starter kit's play.html loads the GrowBlocks engine from growblocks.xyz and runs your game.js, and it shows errors in a panel with "Copy for your AI". Chain calls are simulated; multiplayer uses real rooms. 3. gb.ai in a local build: save your dev key (devkit.html, AI decisions) as `growblocks.key` next to play.html. 4. Check: run `node check.mjs` from the starter kit for static checks, and `node check.mjs --play` to open the game headless and report errors plus a screenshot. `--play` serves your folder itself (on `--port`, default 5199); `--site` is where GrowBlocks runs (default https://growblocks.xyz), not your server. 5. Ship: `node growblocks.mjs login` once, `node growblocks.mjs create
--name "My Game"` once, then `node growblocks.mjs push` uploads a test build and prints its test link, and `node growblocks.mjs submit` sends it for review. (Or upload the folder at https://growblocks.xyz/devkit.html.) With `.mcp.json` in the folder, Claude Code and Cursor get the same as `growblocks_*` tools: docs, check (with screenshot), status, push, submit. Money (collections, coin, earnings) stays on devkit.html. 6. Limits: 200 files, 3 MB per file, 30 MB per build, 20 uploads per game per day, 150 MB per developer across all games. Types: js mjs json css txt png jpg webp gif svg glb gltf bin mp3 ogg wav woff2. 7. Review: every build is reviewed before players get it. While an update of a live game waits, players keep the approved version. 8. Ownership and revenue: the game's collections and coin belong to the developer (created from the developer's own game wallet) and are linked to the game; each NFT belongs to the player who holds it. Of all the fees the game's NFTs and coin generate (1% of coin trades, 2% of mints, 2% of market sales) the developer gets 70% (10% goes to players, 20% to GrowBlocks); part arrives straight from the contracts and GrowBlocks pays the rest. Mint sales, royalties and the creator tax are the developer's on top. Payouts go to the game wallet you first claim with. 9. Names: a game's name and address may not use GrowBlocks, wallet or staff words. ## Terms The GrowBlocks engine (SDK, runtime, avatars and assets loaded from growblocks.xyz) is licensed only for building, testing and publishing games on GrowBlocks. Do not copy, self-host, redistribute or sell it, or use it outside GrowBlocks. Your own game code and art stay yours. Full terms: https://growblocks.xyz/devkit/starter/TERMS.md