Skip to content

Your first working game ​

A real authoritative server, two browser tabs, and a reconnect you can see. Start with a complete project, then change it.

Try the live arena first. Share the invite URL between two tabs, move a player, then disconnect and reload. The hosted example uses WebSocket. The complete Node project below adds an optional native datagram path.

Run it ​

Use Node 22 or newer. Download the standalone starter and extract it into an empty folder. In that folder:

sh
npm install
npm start

Open http://127.0.0.1:8787 in two tabs. You should see an amber player for yourself and a violet player for the other tab. WASD, arrow keys and a drag inside the arena all move your player.

Alternatively, clone the source:

sh
git clone https://github.com/studiokimeiga/twinwire.git
cd twinwire/examples/starter
npm install
npm start

Prove recovery ​

  1. Click Send reliable ping. Your score increases once when the server acknowledges it.
  2. Click Disconnect for 2 seconds. The arena waits for a fresh snapshot.
  3. Watch the same session ID and score return; the resume count increases.
  4. Reload this tab. Its sessionStorage checkpoint resumes the same player within the 30-second grace period.
  5. Add ?rtc=0 to a new tab for a WebSocket-only control.

A checkpoint contains a private resume token: keep it in a per-player store, never put it in a shared URL. An expired session needs an explicit new start; old actions are not sent to a new player.

See where state spends time ​

Open State timing below the arena and enable recording. The same invite and player resume after the page reloads. The recorder retains at most 512 metadata samples and reports state age on receipt and when the game submits its canvas draw, along with server-clock uncertainty. Disable it to remove the trace observer.

These ages include their stated boundaries: the draw mark is an application submission, not GPU presentation. They do not isolate browser scheduling or game playback into network delay. Read the tracing contract.

The server ​

This is the complete starter server, including its simulation and static-file routes:

js
// A complete authoritative game. npm install && npm start; open two tabs.
import {readFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {createGameServer,createNativeRtcFactory} from 'twinwire/server';
const port=Number(process.env.PORT??8787), host=process.env.HOST??'127.0.0.1';
const players=new Map(), encoder=new TextEncoder(), decoder=new TextDecoder();
let rtc;
if(process.env.RTC==='1') rtc=await createNativeRtcFactory({port:Number(process.env.RTC_PORT??port+1)});
const files={
  '/':[new URL('./public/index.html',import.meta.url),'text/html; charset=utf-8'],
  '/game.mjs':[new URL('./public/game.mjs',import.meta.url),'text/javascript'],
  '/twinwire.mjs':[new URL('./dist/browser/twinwire.mjs',import.meta.resolve('twinwire/package.json')),'text/javascript'],
};
const allowed=process.env.ORIGINS?.split(',')??['http://127.0.0.1:'+port,'http://localhost:'+port];
export const server=await createGameServer({
  port,host,rtc,origins:allowed,resumeGraceMs:30_000,maxSessions:8,maxConnections:16,maxPerIp:16,
  onSession(session,resumed){
    if(!resumed) players.set(session.id,{id:session.id,x:40+players.size%8*40,y:100,dx:0,dy:0,at:0,score:0});
    const player=players.get(session.id);
    if(!player)return;
    session.onState=state=>{
      if(state.stream!==1||state.data.length>80)return;
      let input;try{input=JSON.parse(decoder.decode(state.data));}catch{return;}
      if(!input||!Number.isFinite(input.dx)||!Number.isFinite(input.dy))return;
      const length=Math.hypot(input.dx,input.dy);
      player.dx=input.dx/Math.max(1,length);player.dy=input.dy/Math.max(1,length);player.at=performance.now();
    };
    session.onEvent=event=>{
      if(event?.type!=='ping')return;
      // Reliable actions are ordered once per session, even across reconnects.
      player.score++;session.sendEvent({type:'pong',score:player.score});
    };
  },
  onDisconnect(session){const p=players.get(session.id);if(p){p.dx=0;p.dy=0;p.at=0;}},
  onExpire(session){players.delete(session.id);},
  async http(req,res){
    const file=files[new URL(req.url??'/','http://local').pathname];
    if(!file){res.writeHead(404);res.end();return;}
    try{const body=await readFile(fileURLToPath(file[0]));res.writeHead(200,{'content-type':file[1],'cache-control':'no-store'});res.end(body);}
    catch{res.writeHead(404);res.end();}
  },
});
let tick=0;
const timer=setInterval(()=>{
  const now=performance.now();
  for(const p of players.values()){
    const active=now-p.at<200;
    p.x=Math.max(12,Math.min(628,p.x+(active?p.dx:0)*120/60));
    p.y=Math.max(12,Math.min(308,p.y+(active?p.dy:0)*120/60));
  }
  if(++tick%3)return;
  const bytes=encoder.encode(JSON.stringify({tick,players:[...players.values()].map(({id,x,y,score})=>({id,x,y,score}))}));
  for(const s of server.sessions.values())if(s.connected)s.sendState(0,bytes);
},1000/60);
await server.listen();
console.log('Twinwire starter: http://'+host+':'+port+' (RTC '+(rtc?'enabled':'off')+')');
async function stop(){clearInterval(timer);await server.close();process.exit(0);}
process.once('SIGTERM',stop);process.once('SIGINT',stop);

The game validates direction, normalizes diagonal movement, caps speed, and stops stale input. Twinwire retains the session through a socket interruption; the game removes its player only when the session expires. Replaceable direction is stream 1. The complete authoritative world is stream 0. A ping is a reliable event.

The page ​

The starter serves the browser module from the same installed package as the server. It has no CDN version to drift and no bundler to configure.

js
import {GameClient,StateTraceBuffer} from './twinwire.mjs';
const $=id=>document.getElementById(id),canvas=$('arena'),ctx=canvas.getContext('2d');
const url=new URL('/connect',location.href);url.protocol=location.protocol==='https:'?'wss:':'ws:';
const trace=new URLSearchParams(location.search).get('trace')==='1'?new StateTraceBuffer(512):null;
const timingUrl=new URL(location.href);if(trace)timingUrl.searchParams.delete('trace');else timingUrl.searchParams.set('trace','1');
$('timing-toggle').href=timingUrl.href;$('timing-toggle').textContent=trace?'Turn timing off':'Record timing';
if(trace)$('timing-panel').open=true;
const key='twinwire-starter:'+url;
function forget(){try{sessionStorage.removeItem(key);}catch{}}
let checkpoint;try{checkpoint=JSON.parse(sessionStorage.getItem(key)||'null');}catch{forget();}
const options={url:url.href,room:'starter',...(trace?{endpoint:{trace:event=>trace.record(event)}}:{}),rtc:new URLSearchParams(location.search).get('rtc')==='0'?false:undefined,webtransport:new URLSearchParams(location.search).get('rtc')==='0'?false:undefined,...(checkpoint?{checkpoint}:{})};
let net;try{net=new GameClient(options);}catch{forget();delete options.checkpoint;net=new GameClient(options);}
let world={players:[]},resumes=0,lastVia='none',input={dx:0,dy:0};
const decoder=new TextDecoder();
const receiver=net.receiveState({stream:0,reset:()=>{world={players:[]};},apply:state=>{
  let next;try{next=JSON.parse(decoder.decode(state.data));}catch{return false;}
  if(!next||!Array.isArray(next.players)||!Number.isInteger(next.tick))return false;
  world=next;lastVia=state.via;return true;
}});
function save(){try{const cp=net.checkpoint();if(cp)sessionStorage.setItem(key,JSON.stringify(cp));}catch{/* Storage is optional; in-page recovery still works. */}}
net.onSession=info=>{trace?.clear();if(info.resumed)resumes++;save();};
net.onEvent=event=>{if(event?.type==='pong'){$('messages').textContent='Acknowledged. Your score is '+event.score;queueMicrotask(save);}};
net.onSessionLost=info=>{forget();$('new').hidden=false;$('messages').textContent='Session ended: '+info.reason+'. Start a new session to play again.';};
net.onError=error=>{$('messages').textContent=error.message;};
net.bindPageLifecycle({unload:'pause'});addEventListener('pagehide',save);
net.connect();
$('ping').onclick=()=>{if(net.status==='online'){net.sendEvent({type:'ping'});save();}};
$('drop').onclick=()=>{net.pause();setTimeout(()=>net.connect(),2000);};
$('new').onclick=()=>{$('new').hidden=true;net.newSession();};
const keys=new Set();
function directions(){input={dx:Number(keys.has('d')||keys.has('arrowright'))-Number(keys.has('a')||keys.has('arrowleft')),dy:Number(keys.has('s')||keys.has('arrowdown'))-Number(keys.has('w')||keys.has('arrowup'))};}
addEventListener('keydown',e=>{if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)&&document.activeElement===canvas)e.preventDefault();keys.add(e.key.toLowerCase());directions();});
addEventListener('keyup',e=>{keys.delete(e.key.toLowerCase());directions();});
addEventListener('blur',()=>{keys.clear();directions();});
let start;
canvas.onpointerdown=e=>{canvas.focus();canvas.setPointerCapture(e.pointerId);start={x:e.clientX,y:e.clientY};};
canvas.onpointermove=e=>{if(!start)return;input={dx:(e.clientX-start.x)/40,dy:(e.clientY-start.y)/40};};
canvas.onpointerup=canvas.onpointercancel=()=>{start=null;input={dx:0,dy:0};};
const encode=new TextEncoder();
setInterval(()=>{if(net.status==='online')net.sendState(1,encode.encode(JSON.stringify(input)));},1000/30);
function draw(){
  ctx.clearRect(0,0,640,320);
  if(receiver.ready)for(const p of world.players){ctx.fillStyle=p.id===net.id?'#ffb454':'#919cff';ctx.beginPath();ctx.arc(p.x,p.y,10,0,Math.PI*2);ctx.fill();ctx.fillStyle='#e7e9f4';ctx.fillText(p.id.slice(0,5)+' · '+p.score,p.x+14,p.y+4);}
  if(trace&&receiver.ready)receiver.markRendered();
  const status='Session '+(net.id??'pending')+' · '+net.status+' · '+net.transport+' · world '+(receiver.ready?'ready':'recovering')+' · resumes '+resumes;
  if($('status').textContent!==status)$('status').textContent=status;
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
if(trace)setInterval(()=>{
  const summary=trace.summary(),values=trace.snapshot().map(e=>e.clockUncertaintyMs).filter(Number.isFinite);
  const ms=metric=>metric?metric.p50.toFixed(2)+' ms':'waiting';
  $('timing').textContent='World receipt age p50: '+ms(summary.receiveAgeMs)+' · render submission age p50: '+ms(summary.renderAgeMs)+
    ' · clock uncertainty up to '+(values.length?Math.max(...values).toFixed(2)+' ms':'unknown')+' · metadata '+summary.retained+'/512';
},500);
// A small read-only observability surface for the accompanying integration check.
window.starter={net,receiver,get world(){return world;},get resumes(){return resumes;},get via(){return lastVia;},get timing(){return trace?.summary()??null;}};

The recovery boundary is the important part: receiveState decodes and applies a usable snapshot before releasing playback. A socket reconnect alone does not mean the world is ready. Its reset callback clears old world history on both a new session and a resume. The recovery guide explains the choices.

Add datagrams ​

The starter includes optional node-datachannel 0.33.x. Start with WebSocket, then:

sh
RTC=1 npm start
powershell
$env:RTC='1'
npm start

The server uses UDP 8788 by default. A requested RTC start fails if the native module cannot load. The browser initially connects over WebSocket and upgrades only when probe replies prove the datagram path. The same session falls back when that proof expires. Opening a data channel alone is insufficient.

Add rooms and prediction ​

The starter deliberately has one arena and simple authoritative playback. The party example adds the Lobby, quick play, codes, ready-up, client prediction, interpolation and chat.

Before you ship ​

  • Host the page over HTTPS and its connection over WSS. Configure exact page origins in ORIGINS.
  • Set HOST=0.0.0.0 for a remotely accessible server. Open its TCP port and, for RTC, the configured UDP port.
  • Validate actions and input on the server. Origins are not authentication; use authenticate for accounts.
  • Keep persistence, authority and collision in your game. A network ACK is not a saved database transaction.
  • Deploy browser and server together when changing Twinwire versions or your game protocol.

Operations covers reverse proxies, limits, TURN and rollout. Compatibility and verification describes what the release checks establish.

Twinwire is MIT licensed. Source on GitHub, package on npm. Made by Studio Kimeiga.

MIT licensed. Source on GitHub, package on npm.