Where do you go to just... exist?
Not to buy something. Not to consume content. Not to be monetized by an algorithm optimizing for engagement. Just to be present, maybe meet someone, maybe not. To linger without justification.
I've been thinking about this question for a while. The answer used to be obvious: parks, plazas, libraries, church steps, the town square. Sociologists call these "third places", spaces that aren't home (first place) or work (second place) where community forms organically. You show up, you stay, you talk to strangers or you don't. No transaction required.
Those spaces are disappearing. In the physical world, we're left with commercial venues: bars, restaurants, coffee shops, concerts. Places where presence requires purchase. In the digital world, we have social platforms where algorithms determine who sees what based on engagement metrics and advertising revenue. Even our attention is monetized.
I wanted to build a game about what that feels like.
No Loitering is an anti-platformer where standing still costs money. The floor is slippery by default, friction is a luxury you rent by the second. Linger on any platform too long and an enforcement drone arrives to move you along. The goal is always the same: find a free space to advance. But when you get there, you're placed in an increasingly difficult environment, and the cycle repeats.
Everything costs money. But the more money you have, the fewer barriers exist.

Here's how I built it.
Note: This is a technical post about game design and implementation. I'll include code samples and discuss architecture, but the focus is on how mechanics embody the theme, how physics can be social commentary.
The Core Inversion
Traditional platformers give you a problem (gap, enemy, obstacle) and tools to solve it (jump, attack, power-up). The ground is stable. You belong there.
No Loitering inverts this. The floor itself doesn't want you. Every surface has a friction coefficient, and the default is 0.995, meaning you retain 99.5% of your velocity every frame. At 60fps, that's almost no energy loss. You slide. Constantly. The space is designed to keep you moving.
// constants.ts
export const PHYSICS = {
FRICTION_SLIPPERY: 0.995, // Ice-like, default state
FRICTION_ROUGH: 0.85, // Safe zones only
FRICTION_RENT: 0.6, // When you're paying
// ...
};
The only way to stop is to pay for friction. Hold the rent key (Space) and you apply 0.6 friction. But it costs $0.15 per frame. At 60fps, that's $9 per second. The economy isn't separate from the physics; it is the physics.
This is what it feels like to exist in a space that monetizes presence. You can stay, but you're paying for it. The coffee shop lets you sit, but there's an implicit transaction. The park bench has armrests designed to prevent sleeping. The mall is climate-controlled, but security will ask what you're doing if you're not shopping.
In No Loitering, the question isn't "can I make this jump?" It's "can I afford to stop?"
The Economy as Physics Engine
Most games treat currency as progression. You collect coins, buy upgrades, unlock content. The number goes up, and going up feels good.
In No Loitering, currency is survival and also access. You start with $30. Everything costs money:
| Action | Cost | Notes |
|---|---|---|
| Renting friction | $0.15/frame | ~$9/second at 60fps |
| Jumping | $10 flat | Only when grounded |
| Pausing | $1/2sec | Yes, thinking costs money |
| Wall collision | 10-50% | Varies by difficulty |
| Falling into void | 7-14% | Unless Pro tier |
The gigs scattered across each level restore currency ($1, $5, $10, $20, or the rare $50 "Golden Parachute"). But they're placed on slippery platforms you can barely control. You have to earn the money to afford the friction to collect the money to afford more friction.
This is a treadmill. That's intentional.
Tier Gating: Wealth as Access
Currency also determines what you can see. The game has three platform types beyond the basics:
- SLIPPERY (white): Default, always visible
- PRO (green): Only visible when currency ≥ $50
- TUNNEL (purple): Only visible when currency ≥ $100, and they blink in and out
This means wealthier players see more paths. The level geometry is identical, but the experience is stratified. If you've dropped below $50, platforms literally disappear. Routes you relied on vanish.

This is the core metaphor of the game. The spaces exist. The paths are there. But whether you can access them depends on your economic status. It's the private club, the gated community, the first-class lounge. The infrastructure serves everyone differently based on ability to pay.
I've watched people play this. The moment they realize the green platform they were aiming for has vanished because they spent too much on rent is visceral. They understood immediately what the game was saying, without me having to explain it.
The Physics Architecture
To run at 60fps with real-time collision detection, the game uses a hybrid state architecture. This was a hard-learned lesson.
The Problem with React State
My first attempt stored player position in React state:
// Don't do this
const [playerPos, setPlayerPos] = useState({ x: 100, y: 100 });
useEffect(() => {
const gameLoop = () => {
setPlayerPos(prev => ({
x: prev.x + velocity.x,
y: prev.y + velocity.y,
}));
requestAnimationFrame(gameLoop);
};
// ...
}, []);
This triggered 60 re-renders per second. React's reconciliation is fast, but not that fast. The game stuttered. GC pauses became visible.
The Solution: Mutable Refs
The physics engine now mutates refs directly:
// GameCanvas.tsx - the actual approach
const playerPosRef = useRef<Vector>({ x: 0, y: 0 });
const playerVelRef = useRef<Vector>({ x: 0, y: 0 });
const currencyRef = useRef<number>(30);
const gameLoop = useCallback(() => {
// Direct mutation, no re-render
playerPosRef.current = PhysicsService.updatePosition(
playerPosRef.current,
playerVelRef.current,
deltaTime
);
// Only update React state on significant events
if (currencyChanged) {
onCurrencyUpdate(currencyRef.current);
}
requestAnimationFrame(gameLoop);
}, [/* stable deps */]);
React state is used for:
- Game status (MENU → PLAYING → GAME_OVER)
- Currency display in the HUD
- Level transitions
- Scores
Everything else lives in refs. The canvas draws from refs. Physics mutates refs. React only sees what the player needs to see.
Stateless Services
The physics and collision logic is extracted into pure, static services:
// PhysicsService.ts (simplified)
export class PhysicsService {
static updateVelocity(
velocity: Vector,
acceleration: Vector,
friction: number,
maxVelocity: number
): Vector {
const newVel = VectorMath.add(velocity, acceleration);
const dampedVel = VectorMath.scale(newVel, friction);
return VectorMath.clampMagnitude(dampedVel, maxVelocity);
}
static applyGravity(
velocity: Vector,
gravity: Vector,
isActive: boolean
): Vector {
if (!isActive) return velocity;
return VectorMath.add(velocity, gravity);
}
}
These are pure functions. No side effects. No internal state. They take the current world, return the next world. This makes them trivially testable. I have 200+ unit tests covering edge cases like zero-velocity bounces and simultaneous multi-platform collisions.
Collision Detection: The Interesting Part
The collision system handles several distinct cases:
- Platform landing: Player overlaps a platform from above
- Wall collision: Player would pass through a platform edge
- Gig collection: Player overlaps a gig's radius
- Void detection: Player falls below all platforms
- Target arrival: Player reaches the goal zone
Each returns a discriminated union event:
type CollisionEvent =
| { kind: 'platform'; platform: Platform; surfaceType: SurfaceType }
| { kind: 'gig'; gig: Gig; gigIndex: number }
| { kind: 'void' }
| { kind: 'target' };
This pattern lets the game loop handle collisions exhaustively:
for (const event of collisionResult.events) {
switch (event.kind) {
case 'platform':
currentFriction = getFriction(event.surfaceType);
break;
case 'gig':
collectGig(event.gig);
break;
case 'void':
if (tier < TierLevel.PRO) applyVoidPenalty();
break;
case 'target':
triggerVictory();
break;
}
}
The Bounce Problem
Wall bounces were surprisingly tricky. The naive approach, reflect velocity across the collision normal, created a bug where players could clip through corners. The collision would detect one wall, bounce the player, but the new trajectory would intersect an adjacent wall that wasn't checked.
The fix was to check collisions on both axes separately:
// Check X movement first
const xCollision = CollisionService.checkWallCollision(
currentPos,
{ x: nextPos.x, y: currentPos.y },
playerRadius,
'x',
platforms
);
// Then check Y movement (using potentially bounced X)
const yCollision = CollisionService.checkWallCollision(
{ x: xCollision.wouldCollide ? currentPos.x : nextPos.x, y: currentPos.y },
nextPos,
playerRadius,
'y',
platforms
);
This ensures each axis is resolved before checking the next. No more corner clips.
The Loitering System
The game's title comes from its most direct commentary. Spend too long on one platform (20 seconds) and an "Enforcer" drone spawns.
// Loiter detection
const timeOnPlatform = now - platformEntryTime;
if (timeOnPlatform > LOITER_LIMIT && !enforcerActive) {
spawnEnforcer(currentPlatform);
}
This is the "No Loitering" sign. The bench with armrests in the middle. The anti-homeless spikes. The security guard asking if you need help finding something. Presence without purchase is suspicious. Staying still is a violation.
The Enforcer is a simple AI: it accelerates toward the platform where you loitered. It's not fast, but it's persistent. It will chase you across the level until you either:
- Leave the level (complete it)
- Rent the compromised platform ($5 to "own" it)
- Die
Option 2 is the key, but it's more limited than it sounds. Renting a platform only protects you on that platform. Jump to another surface and you're back on the clock. Linger too long on the new platform and the Enforcer returns. You've bought a safe spot, not safety.
And if you die? All your rented platforms are gone. The money you spent is still spent. You restart with nothing but whatever currency carried over from your last attempt. Your investments in space don't survive failure.
This creates interesting decisions. Sometimes it's cheaper to just keep moving. Other times, buying a foothold near the exit saves you. But you're always one mistake away from losing everything you've paid for, which I think is a pretty accurate simulation of navigating modern urban space on a precarious income.
Procedural Music
The soundtrack is generated algorithmically using the Web Audio API. No audio files.
// MusicPlayer.tsx (simplified)
const playNote = (frequency: number, duration: number, startTime: number) => {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = 'sawtooth'; // Cyberpunk aesthetic
oscillator.frequency.value = frequency;
gainNode.gain.setValueAtTime(0.3, startTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
};
The music uses a scheduling pattern: notes are queued ~100ms ahead using AudioContext.currentTime. This prevents timing drift and allows smooth transitions between game states.
Three tracks exist:
- Menu: Ambient, low bass, minimal movement
- Playing: Walking bass, rhythmic tension, rising progressions
- Game Over: Descending melody, resolution
All in D-minor. It felt right.
Level Design Philosophy
The game has 15 levels plus 8 tutorial modules. Each level is hand-designed to teach and test specific mechanics:
| Level | Name | Focus |
|---|---|---|
| 1 | Home Sweet Home | Basic sliding, first gigs |
| 5 | Archipelago State | Jump mechanics, islands |
| 7 | Red Tape District | Hostile walls, damage |
| 9 | Invisible Hand | Tier-gated platforms |
| 11 | Kinetic Battery | Bouncer walls into void |
| 15 | The Boardroom | Everything combined |
The naming is intentional. "Red Tape District." "Invisible Hand." "Hostile Takeover." These are spaces you're not supposed to be in. They are corporate, bureaucratic and hostile by design.
But here's the cycle that makes the game feel like the thing it's commenting on: you complete a level by reaching a "free space" (the target area). Victory. Relief. You made it somewhere you could exist without paying.
Then you're placed in the next level. Harder. More hostile walls. Fewer tier-gated platforms. The free space you found was just a waypoint to a more difficult environment.
This is the gentrification treadmill. The affordable neighborhood gets discovered, prices rise, you move further out. The free park gets redeveloped into luxury condos with a "public plaza" that has security. You find a new place. It gets harder to stay. The game doesn't end when you reach safety, it relocates you to somewhere less safe.
Gig Distribution
Gigs are generated algorithmically based on level ID:
// Early levels: many small gigs
// levels.ts
if (levelId <= 7) {
return generateGigs(platforms, { $1: 20, $5: 5 });
}
// Late levels: fewer, larger gigs
if (levelId >= 12) {
return generateGigs(platforms, { $20: 3 });
}
// Final level: one massive payout
if (levelId === 15) {
return generateGigs(platforms, { $50: 1 });
}
This creates a progression arc: early levels train precision with many collection opportunities; late levels reward risk-taking with single high-value targets placed in dangerous locations.
What I Learned
Building No Loitering taught me several things:
1. Mechanics are metaphors, whether you intend them to be or not. The friction system wasn't designed to be "about" anything at first. It was just a physics experiment, what if the floor was always slippery? But once I added the economy, the metaphor crystallized. Every design decision mapped onto something real. Friction as the cost of public space. Rent as access rights. Wealth as literal visibility. The tier system as economic stratification. The game says things I never explicitly wrote.
2. Games can make arguments that essays can't. I can write about how public space is disappearing, how commercial interests colonize every square foot, how algorithms extract value from attention. But those are words. The moment a player realizes they've been priced out of a platform they were standing on, that's felt. The frustration is real. The calculus of whether to rent or keep moving is visceral. Games can make you experience a system, not just understand it.
3. Performance constraints shape architecture. The hybrid React/ref approach wasn't in my original design. I discovered it by hitting 15fps and working backward. The resulting pattern (React for UI, refs for physics) is cleaner than what I would have designed upfront.
4. Stateless services are testable services. Having 200+ tests isn't impressive by itself. What's impressive is that I can refactor the collision system without fear because I know exactly what behavior I'm preserving. Pure functions made this possible.
5. Sound design creates place. Adding procedural music changed the game's feel more than any physics tweak. The low sawtooth bass makes the dystopia real. Players feel surveilled before the Enforcer even spawns. The space feels hostile before the mechanics confirm it.
Why React? (Or: The Learning Project)
A reasonable question: why build a real-time physics game in React?
The honest answer is that this was never meant to be a product. It's a learning project. I wanted to understand frontend UI development better. React's component model, state management patterns, the lifecycle of a browser application, I've worked in Vue.js (years and years and YEARS ago) as well as old-school jQuery (I'm old). Building a game seemed more interesting than building another todo app.
The result is educational in ways I didn't expect. Hitting 15fps taught me more about React's reconciliation than any tutorial. Debugging collision detection taught me about separation of concerns. The refactoring I'm doing now (extracting stateless services, applying SOLID principles to make components more modular) is making me a better frontend developer in ways that transfer directly to "real" applications.
This project has also been a collaboration. I've used Claude Code as a development partner throughout, bootstrapping the initial structure, advising on patterns I wasn't familiar with, and serving as a brainstorming partner when I got stuck. It's a different way of learning: instead of reading documentation and hoping I understood it correctly, I can ask questions, get explanations in context, and iterate on ideas in real-time. The AI doesn't replace understanding, but it accelerates getting there.
The game is buggy. It's a work in progress. The physics has edge cases I haven't handled. Some levels are more frustrating than challenging. The code has sections I wrote at 2am that I'm not proud of.
Eventually, I'll probably port this to a proper game engine like Godot. React and Canvas were never the right tools for a physics platformer. But that's okay. The point was to learn by building something I actually wanted to play, and I've done that. The game exists. It runs. It says something. The rest is iteration.
The Uncomfortable Part
I'll be honest: I don't know if this game is fun.
It's compelling. Playtesters keep playing. They curse at the screen. They restart levels. They develop strategies (hoard money early, spend late; rent the platform next to the goal).
But "fun" implies leisure, and No Loitering is work. It simulates a specific kind of stress: the stress of existing in spaces designed to extract value from your presence. You're always sliding. You're always spending. The game doesn't want you to win; it wants you to keep moving.
Maybe that's the point. The game recreates a feeling that many people experience daily: the low-grade anxiety of being somewhere you're not sure you're allowed to stay. The calculation of whether you can afford to sit down. The awareness that someone might ask you to leave.
Is that fun? I don't know. But it might be meaningful. And maybe "fun" isn't the right metric for a game about the disappearance of spaces where fun can happen freely.
Play It
The game runs in any modern browser. Clone the repo, run npm run dev, and slide. NOTE: The game is currently in development and may not work as expected. There are plenty of bugs and errors right now that I'm working on fixing.
git clone https://github.com/sysn3rd/NoLoitering.git
cd NoLoitering
npm install
npm run dev
Controls:
- WASD/Arrows: Move
- Space: Rent friction (costs money)
- Enter: Jump (costs $10)
- Tab: Rent current platform ($5)
- Shift+Cmd+D: Debug menu (on main screen)
The first level is forgiving. The last level is not.
Additional Resources
Third Places & Public Space
- The Great Good Place - Ray Oldenburg's foundational book on third places
- Hostile Architecture - Wikipedia overview of defensive design
- The Death and Life of Great American Cities - Jane Jacobs on urban space and community
Game Physics & Architecture
- Game Programming Patterns - Bob Nystrom's essential patterns book
- Fix Your Timestep - Glenn Fiedler on deterministic physics loops
- React Performance Optimization - When to use refs vs state
Procedural Audio
- Web Audio API - MDN reference
- Tone.js - Higher-level Web Audio framework
Game Design & Social Commentary
- MDA Framework - Mechanics, Dynamics, Aesthetics paper
- Papers, Please - Another game that makes you feel systemic oppression through mechanics
