I've been building Farshore, a first-person mystery that runs in the browser: three.js, TypeScript and Vite, no engine, no asset packs, and every sound synthesised live in Web Audio. It's in open playtest, and for the first few days nine people played it.
I had played it through many times myself. They found things in an afternoon that I never would have, for one simple reason: I always know where to go. Here's the logging that caught those problems, and the four lessons that came out of it.
The logger: small, opt-in, boring on purpose
The playtest link adds ?playtest=1. With it, the game keeps a small log of events in localStorage:
-
story beats, by recording every change to the game's own story flags (
seedlingWatered=true,chapter=5) - what the player poked at: every examine, use and take
- every hint that fired, every fall rescue, and every press of the "unstick" key
- position every 10 seconds and frame rate every 30
- a boot line with resolution, render tier, build and GPU name
That's everything. Nothing identifies the player. The log is sent to a form endpoint when the player finishes, sends feedback, or closes the tab.
The trick that makes it useful is that every event is stamped with active time: seconds spent actually playing, with the mouse locked. A tea break then doesn't look like being stuck. A small report script turns the logs into a list of stalls, where a stall is three active minutes with no story beat. Each stall is keyed by the beat that finally ended it, alongside what the player tried in the meantime. That list is the to-do list.
Lesson 1: anything the player can change mid-level must be read live
One tester wrote that an item "never retained the light". The log showed they'd actually done the right thing: the story flag was set. But the chapter had copied that flag into a constant when it was built:
// Built once, when the chapter loads. Wrong for anything the player can change.
const answered = flags.subAdultWakened || fromLine;
So the hints kept sending them back to redo it, and redoing it re-ran the whole moment. The fix is to read it live, and to keep build-time copies only for starting visuals:
const answeredAtBuild = flags.subAdultWakened || fromLine; // starting visuals only
const isAnswered = (): boolean => flags.subAdultWakened || fromLine;
Easy to miss when you always play the chapter in the same order and reload between attempts.
Lesson 2: "safe position" has to mean dry ground, on purpose
Like most games, Farshore remembers your last safe position: a spot where you're standing with headroom. The fall net and the unstick key both put you back there. Out at sea there's an invisible floor just under the water, so stepping off the quay is a wade, not a fall into the void.
That floor counted as "safe". So when a player waded out and pressed unstick, the game said "You climb back to solid stone" and left them exactly where they stood. The safe spot had followed them into the sea.
The fix is to name the floors that hold you up but aren't ground:
export const waterFloors = new WeakSet<THREE.Box3>();
// In the grounded check (simplified): standing *only* on a water floor isn't safe.
let wet = false, dry = false;
for (const box of colliders) {
if (!underFeet(box)) continue;
if (waterFloors.has(box)) wet = true; else dry = true;
}
if (headroom && (dry || !wet)) this.lastSafe.copy(this.position);
A dry box level with the water, such as a causeway, still counts, so you can stand safely on a path across the sea.
Lesson 3: every walkable edge over water is a wall, not a drop
The best line in the logs was six fall rescues in three seconds. A player walked off a submerged ledge into deep water. The fall net put them back on the last safe spot, which was the very edge they'd walked off. They were still holding the key, so they walked straight off again. Six times.
The same shape showed up twice more:
- a gap in an invisible fence led to a drop more than twice the player's step height;
- a dock let you step down to the water but never back up.
The rule now is that any floor ending at water ends in a wall. Each case has an end-to-end test that walks off the edge for three seconds and fails if you end up in the water. On the old code it did; now it doesn't.
Lesson 4: coming back has to say what changed
The first person to finish wrote: "I thought I came back to the same level to do the same thing because I haven't finished something." The game uses a hub you return to between chapters, and every return opened with "she is still waiting for you". Every time, it told the player they were behind.
Now a return says what's moved on: you're carrying something new, the day has gone to dusk, night has fallen while you were away. The light changes with each visit too. It's small, but it's the difference between "sent back" and "coming home".
Bonus: blind AI playtesters
I also ran a few AI playtesters with a player's senses only. Each one gets a screenshot and the words on screen, and can do only what a player can: move, look, press E. The page is paused between steps, so thinking time never counts as play time.
They were bad at spatial navigation. All three stalled for good on a key that every human found in under 35 seconds. But they're relentless at walking into edges and reading every line literally. Two of three found the one-way drop at the dock within their first session. I treat them as a pointer to where to look, and change the game only for what a person would plausibly hit.
The takeaway
The expensive part of playtesting isn't finding players, it's knowing what happened while you weren't watching. A few hundred lines of logging turned "a tester said it was confusing" into "they stood here, tried this four times, and left". Most fixes shipped the same day.
If you'd like to see what it looks like from the player's side, Farshore plays in the browser: desktop, mouse and keyboard, about an hour. And if you get stuck, that's exactly what the logs are for.
Top comments (1)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support