---
title: "Placebound: Archon in the Browser"
url: "https://www.dariuszparys.com/placebound-archon-in-the-browser"
author: "Dariusz Parys"
published: 2026-10-28T04:38:25.000Z
updated: 2026-10-28T04:38:25.000Z
description: "I played Archon a lot on the C64 as a teenager. Placebound is my browser version of it, built with Claude Code in TypeScript and Three.js, with figures built in Blender and every online room running in its own Cloudflare Durable Object."
tags:
  - "AI Coding"
  - "Claude Code"
  - "Cloudflare"
  - "Game Development"
  - "TypeScript"
---

# Placebound: Archon in the Browser

I played Archon a lot on the C64 as a teenager. Two players, Light and Dark,
move pieces on a board that looks like chess. When a piece moves onto an enemy
square, the board disappears and the two pieces fight in real time in an
arena. Win the fight and you take the square. Lose it and your piece is gone.

I always liked that mix. The board decides where the fights happen, and the
fights decide who keeps the board.

So I built my own version for the browser. It's called Placebound, and you can
play it at [placebound.dariuszparys.com](https://placebound.dariuszparys.com).
It's a desktop game for two players: at one desk, in two browsers over the
internet, or with game controllers. If you're alone, the lessons teach the game
against a practice opponent.

![The Placebound start screen with buttons for a local game, the lessons, the rulebook, the compendium and an online game](./assets/start-screen.webp "The start screen")

## What Placebound is

The 9×9 board, the light and dark squares, the movement types and the seven
spells follow the original _Archon: The Light and the Dark_. The names, the
figures, the arenas, the hazards and all the combat numbers are mine. The two
sides are called Tide (Light) and Ember (Dark). Each side starts with 18
pieces:

| Tide / Light | Move       | Ember / Dark | Move       |
| ------------ | ---------- | ------------ | ---------- |
| Knight ×7    | Ground 3   | Goblin ×7    | Ground 3   |
| Archer ×2    | Ground 3   | Manticore ×2 | Ground 3   |
| Unicorn ×2   | Ground 4   | Basilisk ×2  | Ground 3   |
| Golem ×2     | Ground 3   | Troll ×2     | Ground 3   |
| Valkyrie ×2  | Fly 3      | Banshee ×2   | Fly 3      |
| Djinni       | Fly 4      | Dragon       | Fly 4      |
| Phoenix      | Fly 5      | Shapeshifter | Fly 5      |
| Wizard       | Teleport 3 | Sorceress    | Teleport 3 |

The Unicorn moving one square further than the Basilisk is from the original.
So is the Shapeshifter, which copies whichever piece it fights.

You win by holding all five power points (A5, E1, E5, E9 and I5) at the same
time, by eliminating the other side, or by imprisoning its last piece. If both
sides lose their last pieces in the same duel, the match is a draw, and so is a
match where 100 actions in a row change nothing on the board. Online, a player
who stays disconnected for 30 seconds forfeits the match.

Some squares are always light, some always dark, and the rest follow a
six-stage luminosity cycle. A piece fighting on a square that favors its side
gets temporary health for that duel: 8 points per luminosity stage, up to 40
on a fixed square of its own color. Between rounds every piece except the
Shapeshifter recovers 2 health, or 6 on a power point. The two commanders, the
Wizard and the Sorceress, can cast each of seven spells once per match:
Teleport, Heal, Shift Time, Exchange, Summon Elemental, Revive and Imprison.
Casting replaces the move, and every cast costs the commander 5 points of
maximum health for the rest of the match.

![The Placebound board at the start of a match, the Tide army in ivory and teal on the left and the Ember army in charcoal and orange on the right](./assets/board.webp "A new match. Every figure faces the enemy's home columns")

Placebound adds these on top of the original:

- Every duel happens inside the arena of the challenged square. There are seven
  layouts, from a 9×9 pillar hall to a 16×8 canyon, and they don't change with
  the luminosity cycle like the barriers in the original.
- Eight squares have a hazard. On D3, F3, D7 and F7 a lava vent opens under one
  fighter every 1.6 seconds, the attacker first and then alternating. On the
  outer power points A5, E1, E9 and I5 a Sentinel turret locks onto the nearer
  fighter and fires a bolt that can hit either of them.
- Online play in two browsers. Anyone else who opens the room link can watch.
- Seven short lessons that teach the game inside the real interface, plus an
  illustrated rulebook with the complete rules.
- Rumble when your fighter gets hit. The original was played with joysticks,
  and in Placebound a controller works for the whole game as well.

The board also turns, tilts and zooms with the mouse, the keyboard or the right
stick, and a compendium shows every figure on a turntable with what it does,
its numbers against the rest of the roster, and three strengths and
weaknesses.

![The challenge screen for a Tide Knight attacking an Ember Goblin on D3, with both health values and the lava vent hazard](./assets/challenge.webp "Before a duel, the challenge screen shows both pieces, the arena and its hazard. The Goblin gets 40 temporary health because D3 is at full dark")

![A Knight and a Goblin in an open arena while a lava vent erupts under the Goblin](./assets/duel.webp "Knight against Goblin on D3. A lava vent erupts under the Goblin")

## How I built it

I built Placebound with Claude Code. I decided what the game should be and
what was wrong with it, and Claude Code wrote the code.

Each big step started with a written plan in `docs/plans/`: the classic rules,
the arenas with their hazards, the game HUD with the tutorial, the Blender
figures, and the board view with the compendium. I described the problem,
Claude Code wrote the plan with what the step has to deliver, which rules it
must follow and how to verify it, and then implemented it. The HUD plan came from two players who told me the game
had no onboarding and that the interface looked like a CRM, with a tiny board
and tiny buttons. The plan wanted the board to cover 80% of the window. That
would have put the side panels over the edge squares, so I chose to keep the
panels beside the board and measure the board against the space between them.

Verification ran in Chrome through the Chrome DevTools MCP server: scripted
key events, prepared positions, two browser contexts for online matches, and
stand-in controllers for the controller work. Each round of checks is written
down in the repository with what was expected, what happened, and what wasn't
covered.

For the controller work and the rumble I also had review agents go over each
diff from different angles. The controller review found 14 defects, among
them menu input that steered an online fighter and a lone controller in the
second slot doing nothing in lessons. The rumble review found three. Online
kills never triggered the fatal rumble, because the server skipped the last
snapshot of a duel. And the Banshee's scream deals a little damage on every
simulation step, which never added up to a rumble. All of them are fixed and
have regression tests.

### The figures

The figures started as Three.js primitives assembled in code: a cylinder
body, a sphere head, a cone for the plume. When I set up the Blender MCP
server and asked Claude Code to model the Knight, it rebuilt that Knight in
Blender from the game's own code, and seeing it there made clear how abstract
all 20 figures were. A first redesign stayed in code. Then I asked for the
real thing: every figure modelled in Blender, loaded by the game as a model,
recognisable from the board camera, and as close to a professional game model
as the process allows.

Nobody sculpted in Blender's interface. Each of the 20 kinds has a Python
script that builds it in a background Blender on a shared contract: Blender
units are the game's units, a height range per tier so a commander always
stands tallest, a footprint, a budget of 18,000 triangles, and six material
roles named body, cloth, skin, trim, metal and eye. The game colours each role
with the owning side's palette, so one model serves Tide, Ember and a
Shapeshifter's copy. Ambient occlusion and edge highlights are baked into the
vertex colours, because there are no texture maps, no rigs and no animation.
The build writes a `.blend` file to open and inspect and a Meshopt-compressed
GLB that the game loads. The scripts came from a multi-agent workflow with an
art director that reviewed every figure in three rounds against an AAA bar. It
approved only the Golem and the Banshee. The Water elemental scored 5 of 10
after four rounds of redesign, and I accepted it when I saw it on the board.
Those scripts are about 19,500 lines of Python, more than the game itself, and
almost all of them written by agents.

![All 20 kinds for both sides on one board, from the Knight to the four elementals](./assets/figures.webp "All 40 figures in the lineup scenario, a development position that puts every kind for both sides on shuffled squares")

A blind test checked whether the figures read from the board camera: twelve
judges named 239 of 240 pieces on four boards from the roster alone. The
judges were AI models choosing from a closed list of 20 names, which is easier
than a player naming a piece unprompted. My own decision cost some of that. I
wanted every figure to face the enemy's home columns instead of the camera,
and side-on the Dark pieces read worse: 44 of 60 instead of 60 of 60 before,
mostly the Shapeshifter, the Banshee and the Troll. I kept the facing. The game also found a bug the Blender previews never showed: on the
board, cloth and skin stayed at model size inside the plinth, so Dark pieces
showed flat orange shapes where their robes should be.

![The compendium page for the Dragon, with the figure on a turntable in Ember colours, its numbers against the roster, and its strengths and weaknesses](./assets/compendium.webp "The compendium. The Dragon in Ember colours, with its numbers against the rest of the roster")

The models add about 3 MB of compressed downloads before the first frame,
behind a loading screen. The primitives needed none of that. The compendium text
was written by agents too, four writers and three adversarial reviewers who
reported 54 problems, and an editor checked each claim against the code.

The result is about 12,000 lines of TypeScript. Roughly 8,400 are the game,
3,400 are 158 tests, and 180 are the Cloudflare Worker. The rules, the combat
and the online room have unit tests. The two largest files, the UI wiring and
the Three.js view, are covered only by the browser checks. The repository is
private, so the excerpts in this post are the code you get to see.

## The stack

There's no game engine beyond Three.js for rendering. Vite bundles the
TypeScript, Three.js draws the board and the arenas and loads the 20 figure
models with its own glTF loader and Meshopt decoder, and the fonts (Cinzel and
Inter) come from Fontsource. Those three packages are the only runtime
dependencies.

The rules are plain TypeScript without any rendering. `movement.ts`,
`cycle.ts`, `health.ts` and `spells.ts` answer questions about a match, and
`rules.ts` applies an action. The real-time fight lives in a `Combat` class that
advances in fixed steps of 1/120 of a second. It doesn't know about Three.js,
the DOM or the network. The browser draws whatever state it holds.

Because of that separation, the tests run in Node with its built-in test
runner through `tsx`, without a browser, and the same `Combat` class runs in
the browser for local games and on the server for online games.

## Online play on Cloudflare

The whole game, static files and online rooms, is deployed as one Cloudflare
Worker on the Workers Free plan. This is its `wrangler.jsonc` without the
comments:

```json
{
  "name": "placebound",
  "main": "worker/index.ts",
  "compatibility_date": "2026-09-01",
  "routes": [
    { "pattern": "placebound.dariuszparys.com", "custom_domain": true }
  ],
  "workers_dev": false,
  "preview_urls": false,
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "run_worker_first": ["/ws/*"]
  },
  "durable_objects": {
    "bindings": [{ "name": "ROOMS", "class_name": "RoomObject" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RoomObject"] }]
}
```

These are the Cloudflare pieces it uses:

| Component                 | What it does in Placebound                                                               |
| ------------------------- | ---------------------------------------------------------------------------------------- |
| Workers                   | Routes `/ws/<room>` to the room, everything else to the static files                     |
| Workers Static Assets     | Serves the Vite build from `dist/`                                                       |
| Durable Objects           | One object per room code, runs the match and the duels                                   |
| Durable Object storage    | Keeps the room state in the object's SQLite storage                                      |
| WebSocket Hibernation API | Keeps sockets open while the object isn't running                                        |
| Durable Object alarms     | Ends a match 30 seconds after a player disconnects, deletes a room after 24 hours        |
| Custom Domains            | `placebound.dariuszparys.com`, with the DNS record and certificate managed by Cloudflare |

### One Durable Object per room

`run_worker_first: ["/ws/*"]` means Cloudflare serves the HTML, JavaScript and
fonts directly. The Worker code only runs for room sockets and for requests the
asset server has no file for:

```ts
export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);
    const route = url.pathname.match(/^\/ws\/([^/]+)$/);
    if (!route) return env.ASSETS.fetch(request);
    const code = route[1].toUpperCase();
    if (!isRoomCode(code)) return new Response("Unknown room", { status: 404 });
    if (request.headers.get("Upgrade") !== "websocket")
      return new Response("Expected a WebSocket upgrade", { status: 426 });
    return env.ROOMS.get(env.ROOMS.idFromName(code)).fetch(request);
  },
} satisfies ExportedHandler<Env>;
```

When you click **Create online game**, the browser picks a six-character room
code and opens `?room=CODE`. `idFromName(code)` maps that code to the same
Durable Object for everyone who opens the link, and Cloudflare creates the
object on first use. Every message for a room arrives at that one object, so it
decides the order of events without any locking.

The room code is the only secret. Anyone with the link can take an empty seat
or watch, which is what you want for a game you send to a friend. The room
checks the shape of every message before it acts on it, and the Worker drops
messages over 4 KB.

The room is authoritative. On the board, a browser sends a command, such as
moving a piece or casting a spell, and the room checks it with the same rules
code before it applies it. In a duel, a browser only sends its controls: a
direction and whether the attack button is down. The room runs the fight in
steps of 1/120 of a second and sends a snapshot about 30 times a second. The
browser draws the duel 60 milliseconds in the past and interpolates between two
snapshots, so the fighters move smoothly instead of jumping with every packet.
The hazards are simulated only on the server. The browsers just draw them.

The price of that is input latency. There is no client-side prediction, so
your own fighter reacts one round trip after you press a key. I chose the
simpler server-only model, and whether the delay matters in a close fight is
something only more matches will show.

This is the shape of an online duel, leaving out the lobby messages, the
spectators and the error cases:

```mermaid
sequenceDiagram
    participant T as Browser (Tide)
    participant R as Room Durable Object
    participant E as Browser (Ember)
    T->>R: WebSocket /ws/CODE, claim seat, ready
    E->>R: WebSocket /ws/CODE, claim seat, ready
    R-->>T: started
    R-->>E: started
    T->>R: command (attack a square)
    R-->>T: encounter
    R-->>E: encounter
    loop duel
        T->>R: controls
        E->>R: controls
        R-->>T: snapshot (~30 per second)
        R-->>E: snapshot (~30 per second)
    end
    R-->>T: final snapshot, settled
    R-->>E: final snapshot, settled
```

The `Room` class that holds the game logic doesn't know it runs in a Durable
Object. It gets two callbacks, `send` and `save`. In production the Durable
Object provides them, and in the tests plain functions do. That's how the
online tests run rooms in Node.

### Hibernation and cleanup

The room accepts its sockets through the WebSocket Hibernation API. Between
moves, while a player thinks, nothing runs, and Cloudflare can evict the object
from memory while the sockets stay open. A 60 Hz timer runs only during the
duel countdown and the fight, and only while both fighters are connected. That
second condition was missing at first. A pending timer keeps a Durable Object
resident and billed, so a duel paused by a disconnect would have kept the
object awake until the room was deleted a day later. At 128 MB that is about
10,800 GB-seconds, and the Free plan allows 13,000 per day. Two abandoned duels
on the same day would have blocked online play for the rest of the day. Now the
timer stops when a fighter drops out, and the object's alarm takes over the
timing.

When the object starts again, its constructor loads the room state from
storage and re-attaches the open sockets. If the object restarts in the middle
of a duel, the duel starts over from the saved encounter with a new countdown.

When the last connection closes, players and spectators alike, the room notes
the time and sets its alarm 24 hours ahead. If anyone reconnects, the alarm is
cancelled. If nobody does, the alarm deletes the room's storage. A Durable
Object has one alarm, so the same alarm also serves the 30-second forfeit clock
described below, and the Worker always arms it for whichever deadline comes
first.

### Seats and reconnects

Each browser creates a random token per room and keeps it in `localStorage`.
The token is the seat. A reload or a dropped connection reconnects with the same
token, and the player gets the same side back. The client retries with a
growing delay, capped at five seconds.

While a seated player is disconnected during a match, the game pauses and the
opponent sees a 30-second countdown with a **Claim victory** button. If the
player returns in time, the match continues where it stopped. If not, or if the
opponent claims the win, the match ends by abandonment. If both players stay
away for 30 seconds, it's a draw. The clock runs on the object's alarm, so it
works even when the object has been evicted from memory in the meantime.

### Domain and cost

The `routes` entry with `custom_domain: true` makes Cloudflare create the DNS
record and the certificate for `placebound.dariuszparys.com` on deploy.
`workers_dev` and `preview_urls` are off, so that's the only address the game
answers on. Deploying is `npm run deploy`, which builds and runs
`wrangler deploy`.

Everything runs on the Workers Free plan. The room class is declared with
`new_sqlite_classes`, so it uses the SQLite storage backend, which is the only
Durable Object backend the Free plan supports. The custom domain on a zone I
already had on Cloudflare didn't need a paid plan either.

## Controllers and rumble

The game accepts any controller the browser reports with the `standard`
button mapping, which is what current browsers report for common Xbox and
PlayStation pads. In a local match, the first controller plays Tide and the
second plays Ember. Outside duels, the controller moves the keyboard focus
around the board and the menus, and pressing A clicks the focused button. The
right stick turns and tilts the board, the triggers zoom it, and LB and RB page
through the compendium. Every press goes through the same handlers as a mouse
click, so there are no separate rule checks for controllers. The fighter
plates and the rulebook show the button names of the connected controller, so
a PlayStation player sees ✕ instead of A.

I've played it with a DualSense, and it works. The earlier checks used
stand-in controllers, Xbox and DualSense identifiers injected into
`navigator.getGamepads()`, so a physical Xbox pad hasn't been tested yet.

When your fighter gets hit, your controller rumbles through the Gamepad API's
`vibrationActuator.playEffect("dual-rumble", ...)`. Heavier hits rumble harder
and longer:

```ts
export function hitRumble(damage: number, fatal: boolean): Rumble {
  if (fatal) return { duration: 420, strongMagnitude: 1, weakMagnitude: 0.8 };
  const strength = Math.min(1, 0.35 + damage / 60);
  return {
    duration: Math.round(Math.min(260, Math.max(100, 80 + damage * 4))),
    strongMagnitude: strength,
    weakMagnitude: strength * 0.7,
  };
}
```

A Knight's strike gives about 0.13 seconds, a Dragon's about 0.26, and a fatal
blow 0.42. The Banshee's scream and the Phoenix's fire hurt a little on every
step, so that damage is added up and reported at most every 0.1 seconds. The
result is a steady buzz at any frame rate. The stand-in controllers recorded
every `playEffect` call with the expected durations and strengths, and on the
DualSense the rumble felt right.

Firefox and Safari don't count a controller press as user activation, so they
may refuse **Copy link** when you press it with a controller. The game then selects the link in the text field so you can copy
it yourself.

## What's missing

- The combat numbers are untuned defaults. There has been no competitive human
  playtest, so nobody knows whether Tide or Ember is stronger.
- There is no computer opponent, although the original had one. The lessons
  have practice opponents, but a full match needs a second person, next to you
  or online.
- Phones and tablets aren't supported. The game needs a desktop browser with
  WebGL2, and there are no touch controls.
- There is no client-side prediction, so your own fighter moves one round trip
  late in online duels.

## Try it

Open [placebound.dariuszparys.com](https://placebound.dariuszparys.com) in a
desktop browser. If you're alone or never played Archon, click **Learn to
play**: the first lesson takes about two minutes and ends in a real duel. If
you only want to look at the figures, open the **Compendium**. When
you have an opponent, click **Create online game** and send them the link, or
sit at one desk. And if you played Archon on the C64 too, I'd like to know
how close it feels.
