Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created September 1, 2026 17:51
Show Gist options
  • Select an option

  • Save EncodeTheCode/683807d9f4ba0abcace762abc4ff07ef to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/683807d9f4ba0abcace762abc4ff07ef to your computer and use it in GitHub Desktop.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GTA 2 Style Wanted System</title>
<style>
html,body{margin:0;background:#111;color:#fff;font:14px Arial;overflow:hidden}
canvas{display:block;margin:auto;background:#20242a;cursor:crosshair}
#ui{position:fixed;inset:0;pointer-events:none}
#info{position:absolute;left:12px;top:10px;text-shadow:1px 1px #000}
#reload{
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:260px;padding:10px;background:#111c;border:2px solid #fff;
text-align:center;visibility:hidden
}
#rb{height:12px;margin-top:8px;background:#333}
#rf{height:100%;width:0;background:#fff}
</style>
</head>
<body>
<canvas id="c" width="960" height="600"></canvas>
<div id="ui">
<div id="info"></div>
<div id="reload">
<b>RELOADING</b>
<div id="rb"><div id="rf"></div></div>
</div>
</div>
<script>
const c=document.getElementById("c"),x=c.getContext("2d"),W=c.width,H=c.height;
const reloadUI=document.getElementById("reload"),reloadFill=document.getElementById("rf");
const keys={},mouse={x:W/2,y:H/2,down:false};
addEventListener("keydown",e=>{
let k=e.key.toLowerCase();
keys[k]=1;
if(k==="r")reload(player);
if(k==="q"&&!e.repeat)nextWeapon();
});
addEventListener("keyup",e=>keys[e.key.toLowerCase()]=0);
c.onmousemove=e=>{
let r=c.getBoundingClientRect();
mouse.x=e.clientX-r.left;
mouse.y=e.clientY-r.top;
};
c.onmousedown=()=>mouse.down=1;
c.onmouseup=()=>mouse.down=0;
/* ---------------- WORLD ---------------- */
const WORLD={w:1800,h:1200,grid:40};
const walls=[
{x:120,y:80,w:420,h:40},
{x:650,y:80,w:40,h:300},
{x:780,y:40,w:500,h:40},
{x:250,y:250,w:40,h:400},
{x:420,y:500,w:420,h:40},
{x:930,y:250,w:420,h:40},
{x:1040,y:450,w:40,h:400},
{x:1400,y:120,w:40,h:500},
{x:1320,y:760,w:350,h:40},
{x:700,y:850,w:40,h:250},
{x:180,y:850,w:350,h:40}
];
/*
Every weapon keeps its own:
clip = bullets currently loaded
reserve = spare ammunition
*/
const weapons={
pistol:{
name:"Pistol",damage:20,rate:300,reload:900,
mag:12,ammo:48,speed:850,spread:.02,pellets:1,color:"#ddd"
},
smg:{
name:"SMG",damage:10,rate:90,reload:1200,
mag:30,ammo:120,speed:950,spread:.07,pellets:1,color:"#ffdf55"
},
rifle:{
name:"Rifle",damage:28,rate:240,reload:1400,
mag:20,ammo:80,speed:1200,spread:.015,pellets:1,color:"#77dfff"
},
shotgun:{
name:"Shotgun",damage:13,rate:700,reload:1600,
mag:6,ammo:30,speed:800,spread:.20,pellets:6,color:"#ff8f62"
}
};
const weaponOrder=["pistol","smg","rifle","shotgun"];
/* ---------------- UTIL ---------------- */
function clamp(v,a,b){return Math.max(a,Math.min(b,v))}
function len(a,b){return Math.hypot(a,b)}
function norm(a,b){
let l=Math.hypot(a,b)||1;
return [a/l,b/l];
}
function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y)}
function blocked(cx,cy,r){
return walls.some(w=>{
let qx=clamp(cx,w.x,w.x+w.w);
let qy=clamp(cy,w.y,w.y+w.h);
return (cx-qx)**2+(cy-qy)**2<r*r;
});
}
function move(o,dx,dy){
let nx=o.x+dx;
if(!blocked(nx,o.y,o.r))o.x=clamp(nx,o.r,WORLD.w-o.r);
let ny=o.y+dy;
if(!blocked(o.x,ny,o.r))o.y=clamp(ny,o.r,WORLD.h-o.r);
}
function visible(a,b){
let dx=b.x-a.x,dy=b.y-a.y;
let d=Math.hypot(dx,dy);
let n=Math.ceil(d/20);
for(let i=1;i<n;i++){
let t=i/n;
let px=a.x+dx*t,py=a.y+dy*t;
if(walls.some(w=>
px>w.x&&px<w.x+w.w&&
py>w.y&&py<w.y+w.h
))return false;
}
return true;
}
/* ---------------- A* ---------------- */
const GW=Math.ceil(WORLD.w/WORLD.grid);
const GH=Math.ceil(WORLD.h/WORLD.grid);
function gridOK(gx,gy){
let px=gx*40+20,py=gy*40+20;
return px>=20&&py>=20&&px<WORLD.w-20&&py<WORLD.h-20&&!blocked(px,py,14);
}
function AStar(a,b){
let sx=clamp(Math.floor(a.x/40),0,GW-1);
let sy=clamp(Math.floor(a.y/40),0,GH-1);
let ex=clamp(Math.floor(b.x/40),0,GW-1);
let ey=clamp(Math.floor(b.y/40),0,GH-1);
const key=(x,y)=>x+","+y;
const h=(x,y)=>Math.abs(x-ex)+Math.abs(y-ey);
let open=[{x:sx,y:sy}];
let g={[key(sx,sy)]:0};
let f={[key(sx,sy)]:h(sx,sy)};
let came={};
let guard=0;
while(open.length&&guard++<1400){
open.sort((a,b)=>f[key(a.x,a.y)]-f[key(b.x,b.y)]);
let q=open.shift();
if(q.x===ex&&q.y===ey){
let out=[],k=key(ex,ey);
while(k){
let p=k.split(",").map(Number);
out.push({x:p[0]*40+20,y:p[1]*40+20});
k=came[k];
}
return out.reverse();
}
for(let [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
let nx=q.x+dx,ny=q.y+dy;
if(nx<0||ny<0||nx>=GW||ny>=GH||!gridOK(nx,ny))continue;
let k=key(nx,ny),ng=g[key(q.x,q.y)]+1;
if(g[k]===undefined||ng<g[k]){
came[k]=key(q.x,q.y);
g[k]=ng;
f[k]=ng+h(nx,ny);
if(!open.some(n=>n.x===nx&&n.y===ny))
open.push({x:nx,y:ny});
}
}
}
return [];
}
/* ---------------- ACTORS ---------------- */
function actor(x,y,r=14,speed=120){
return{
x,y,r,speed,
vx:0,vy:0,
acc:900,
decel:1100,
angle:0,
hp:100,
weapon:"pistol",
reloadStart:0,
/* important: each actor stores ammo for every weapon */
ammo:{}
};
}
function initAmmo(){
let a={};
for(let w in weapons){
a[w]={
clip:weapons[w].mag,
reserve:weapons[w].ammo
};
}
return a;
}
/* ---------------- PLAYER ---------------- */
let player=actor(90,700,14,145);
player.ammo=initAmmo();
player.weapon="pistol";
player.sprint=1.8;
player.lastShot=0;
function currentAmmo(o){
return o.ammo[o.weapon];
}
function switchWeapon(w){
if(!weapons[w])return;
/* don't switch away from a reload */
if(player.reloading)return;
player.weapon=w;
player.lastShot=0;
}
function nextWeapon(){
let i=weaponOrder.indexOf(player.weapon);
switchWeapon(weaponOrder[(i+1)%weaponOrder.length]);
}
/* ---------------- WANTED SYSTEM ---------------- */
let wanted=0;
let crimeTime=0;
let spawnTime=0;
function addWanted(v){
wanted=clamp(wanted+v,0,5);
crimeTime=performance.now();
}
function wantedSystem(){
if(performance.now()-crimeTime>12000)
wanted=Math.max(0,wanted-.01);
spawnTime-=16;
let target=Math.ceil(wanted);
let cops=enemies.filter(e=>e.police&&!e.dead).length;
if(wanted>0&&cops<target&&spawnTime<=0){
let a=Math.random()*Math.PI*2;
let d=500;
let p={
x:clamp(player.x+Math.cos(a)*d,30,WORLD.w-30),
y:clamp(player.y+Math.sin(a)*d,30,WORLD.h-30)
};
if(!blocked(p.x,p.y,20)){
makeEnemy(
p.x,
p.y,
target>=4?"rifle":"smg",
true
);
}
spawnTime=4500;
}
}
/* ---------------- PATROLS ---------------- */
const patrols=[
[
{x:70,y:70},
{x:580,y:70},
{x:580,y:420},
{x:70,y:420}
],
[
{x:720,y:520},
{x:900,y:520},
{x:900,y:980},
{x:720,y:980}
],
[
{x:1100,y:100},
{x:1550,y:100},
{x:1550,y:650},
{x:1100,y:650}
]
];
let enemies=[];
function makeEnemy(x,y,weapon="pistol",police=false){
let e=actor(x,y,14,police?105:85);
e.police=police;
e.weapon=weapon;
e.ammo=initAmmo();
e.lastShot=0;
/* More natural NPC ammunition */
e.ammo[weapon].clip=weapons[weapon].mag;
e.ammo[weapon].reserve=weapons[weapon].ammo;
e.state="patrol";
e.route=patrols[(Math.random()*patrols.length)|0].map(p=>({...p}));
e.way=(Math.random()*e.route.length)|0;
e.path=[];
e.pathI=0;
e.lastSeen=0;
e.alert=0;
enemies.push(e);
}
for(let i=0;i<7;i++){
makeEnemy(
150+i*170,
150+(i%3)*220,
["pistol","smg","rifle","shotgun"][i%4]
);
}
/* ---------------- RELOADING ---------------- */
function reload(o){
let a=currentAmmo(o),w=weapons[o.weapon];
if(o.reloading||a.clip>=w.mag||a.reserve<=0)return;
o.reloading=performance.now();
if(o===player)
reloadUI.style.visibility="visible";
}
function updateReload(o){
if(!o.reloading)return;
let w=weapons[o.weapon];
let p=clamp(
(performance.now()-o.reloading)/w.reload,
0,1
);
if(o===player){
reloadUI.style.visibility=p<1?"visible":"hidden";
reloadFill.style.width=(p*100)+"%";
}
if(p>=1){
let a=currentAmmo(o);
let take=Math.min(w.mag-a.clip,a.reserve);
a.clip+=take;
a.reserve-=take;
o.reloading=0;
}
}
/* ---------------- SHOOTING ---------------- */
let bullets=[];
function fire(o,enemy=false){
let w=weapons[o.weapon];
let a=currentAmmo(o);
let now=performance.now();
if(o.reloading||now-o.lastShot<w.rate)return;
if(a.clip<=0){
reload(o);
return;
}
a.clip--;
o.lastShot=now;
if(!enemy)
addWanted(.12);
/*
Shotgun = 6 pellets.
Each pellet receives a random angle inside
the narrow spread cone.
*/
for(let i=0;i<w.pellets;i++){
let ang=o.angle+(Math.random()-.5)*w.spread;
bullets.push({
x:o.x+Math.cos(ang)*o.r,
y:o.y+Math.sin(ang)*o.r,
vx:Math.cos(ang)*w.speed,
vy:Math.sin(ang)*w.speed,
damage:w.damage,
enemy,
life:1000,
rad:3
});
}
}
/* ---------------- ENEMY VISION ---------------- */
function enemyCanSee(e){
let dx=player.x-e.x;
let dy=player.y-e.y;
let d=Math.hypot(dx,dy);
if(d>360)return false;
let dot=
(dx*Math.cos(e.angle)+dy*Math.sin(e.angle))/d;
return dot>Math.cos(Math.PI*.31)&&visible(e,player);
}
/* ---------------- ENEMY DAMAGE REACTION ---------------- */
function enemyHit(e){
/*
Getting hit immediately overrides patrol behavior.
The enemy now faces the player and enters combat.
*/
e.state="chase";
e.alert=performance.now();
e.lastSeen=performance.now();
e.angle=Math.atan2(
player.y-e.y,
player.x-e.x
);
/* give enemies a chance to shoot immediately */
e.lastShot=0;
}
/* ---------------- ENEMY AI ---------------- */
function enemyAI(e,dt){
updateReload(e);
let now=performance.now();
let sees=enemyCanSee(e);
if(sees){
e.state="chase";
e.lastSeen=now;
}
/*
When hit recently, stay alert even if the player
briefly moves behind a wall.
*/
if(e.alert&&now-e.alert<2500){
e.state="chase";
}
/*
Lose player -> after a short delay return to patrol.
*/
if(
e.state==="chase" &&
now-e.lastSeen>4000 &&
now-e.alert>2500
){
e.state="patrol";
e.path=[];
e.alert=0;
}
let tx,ty;
if(e.state==="chase"){
tx=player.x;
ty=player.y;
e.angle=Math.atan2(
ty-e.y,
tx-e.x
);
/*
Enemies can fire as soon as:
- player is in their cone and visible
- OR they were recently hit
*/
if(sees||now-e.alert<1200)
fire(e,true);
}else{
let p=e.route[e.way];
if(dist(e,p)<25){
e.way=(e.way+1)%e.route.length;
e.path=[];
p=e.route[e.way];
}
tx=p.x;
ty=p.y;
if(!e.path.length||e.pathI>=e.path.length){
e.path=AStar(e,{x:tx,y:ty});
e.pathI=1;
}
if(e.path[e.pathI]){
let p2=e.path[e.pathI];
if(dist(e,p2)<18)e.pathI++;
e.angle=Math.atan2(
p2.y-e.y,
p2.x-e.x
);
}
}
let targetSpeed=e.speed;
if(e.state==="chase"&&dist(e,player)<180)
targetSpeed*=.9;
let ax=Math.cos(e.angle)*targetSpeed;
let ay=Math.sin(e.angle)*targetSpeed;
e.vx+=(ax-e.vx)*Math.min(1,dt*5);
e.vy+=(ay-e.vy)*Math.min(1,dt*5);
let sp=Math.hypot(e.vx,e.vy);
if(sp>e.speed){
e.vx*=e.speed/sp;
e.vy*=e.speed/sp;
}
move(e,e.vx*dt,e.vy*dt);
if(currentAmmo(e).clip<=0)
reload(e);
}
/* ---------------- DAMAGE ---------------- */
let loot=[];
let particles=[];
function hurtEnemy(e,d){
e.hp-=d;
/*
Every hit makes the NPC turn around and attack.
*/
enemyHit(e);
if(e.hp<=0){
addWanted(1);
const dropAmmo={
pistol:18,
smg:45,
rifle:30,
shotgun:12
};
loot.push({
x:e.x,
y:e.y,
weapon:e.weapon,
ammo:dropAmmo[e.weapon],
r:10
});
for(let i=0;i<12;i++){
particles.push({
x:e.x,
y:e.y,
vx:(Math.random()-.5)*160,
vy:(Math.random()-.5)*160,
life:400
});
}
e.dead=1;
}
}
/* ---------------- PLAYER ---------------- */
let camera={x:0,y:0};
function cam(){
camera.x=clamp(
player.x-W/2,
0,
WORLD.w-W
);
camera.y=clamp(
player.y-H/2,
0,
WORLD.h-H
);
}
function playerAim(){
cam();
player.angle=Math.atan2(
mouse.y+camera.y-player.y,
mouse.x+camera.x-player.x
);
}
function control(dt){
playerAim();
updateReload(player);
let ix=
(keys.d||keys.arrowright?1:0)-
(keys.a||keys.arrowleft?1:0);
let iy=
(keys.s||keys.arrowdown?1:0)-
(keys.w||keys.arrowup?1:0);
if(ix||iy)[ix,iy]=norm(ix,iy);
let sprint=
keys.shift?
player.sprint:
1;
let tx=ix*player.speed*sprint;
let ty=iy*player.speed*sprint;
let rate=
ix||iy?
player.acc:
player.decel;
player.vx+=
(tx-player.vx)*
Math.min(
1,
rate*dt/Math.max(player.speed,1)
);
player.vy+=
(ty-player.vy)*
Math.min(
1,
rate*dt/Math.max(player.speed,1)
);
move(
player,
player.vx*dt,
player.vy*dt
);
if(mouse.down)
fire(player);
if(currentAmmo(player).clip<=0)
reload(player);
/*
Loot automatically with E.
*/
if(keys.e){
for(let l of loot){
if(dist(player,l)<30){
player.weapon=l.weapon;
currentAmmo(player).clip=
weapons[l.weapon].mag;
currentAmmo(player).reserve+=l.ammo;
l.dead=1;
break;
}
}
}
}
/* ---------------- BULLETS ---------------- */
function updateBullets(dt){
for(let b of bullets){
b.x+=b.vx*dt;
b.y+=b.vy*dt;
b.life-=dt*1000;
if(b.life<=0)continue;
if(
b.x<0||
b.y<0||
b.x>WORLD.w||
b.y>WORLD.h
){
b.life=0;
continue;
}
if(
walls.some(w=>
b.x>w.x&&
b.x<w.x+w.w&&
b.y>w.y&&
b.y<w.y+w.h
)
){
b.life=0;
continue;
}
if(b.enemy){
if(dist(b,player)<player.r+b.rad){
player.hp-=b.damage;
b.life=0;
}
}else{
for(let e of enemies){
if(e.dead)continue;
if(dist(b,e)<e.r+b.rad){
hurtEnemy(e,b.damage);
b.life=0;
break;
}
}
}
}
bullets=bullets.filter(b=>b.life>0);
}
/* ---------------- DRAWING ---------------- */
function drawActor(o,fill){
let px=o.x-camera.x;
let py=o.y-camera.y;
x.fillStyle=fill;
x.beginPath();
x.arc(px,py,o.r,0,Math.PI*2);
x.fill();
x.strokeStyle="#000";
x.stroke();
/* pistol/SMG/rifle/shotgun represented by direction line */
x.strokeStyle="#fff";
x.lineWidth=4;
x.beginPath();
x.moveTo(px,py);
x.lineTo(
px+Math.cos(o.angle)*20,
py+Math.sin(o.angle)*20
);
x.stroke();
x.lineWidth=1;
}
function drawFOV(e){
if(!e.police)return;
let px=e.x-camera.x;
let py=e.y-camera.y;
x.fillStyle="rgba(255,255,80,.07)";
x.beginPath();
x.moveTo(px,py);
x.arc(
px,
py,
360,
e.angle-Math.PI*.31,
e.angle+Math.PI*.31
);
x.closePath();
x.fill();
}
function draw(){
x.clearRect(0,0,W,H);
cam();
/* background */
x.fillStyle="#24292f";
x.fillRect(
-camera.x,
-camera.y,
WORLD.w,
WORLD.h
);
/* roads / background grid */
x.strokeStyle="#333";
for(let i=0;i<WORLD.w;i+=80){
x.beginPath();
x.moveTo(i-camera.x,-camera.y);
x.lineTo(i-camera.x,WORLD.h-camera.y);
x.stroke();
}
for(let i=0;i<WORLD.h;i+=80){
x.beginPath();
x.moveTo(-camera.x,i-camera.y);
x.lineTo(WORLD.w-camera.x,i-camera.y);
x.stroke();
}
/* walls */
x.fillStyle="#454b52";
for(let w of walls)
x.fillRect(
w.x-camera.x,
w.y-camera.y,
w.w,
w.h
);
/* weapon loot */
for(let l of loot){
let w=weapons[l.weapon];
x.fillStyle=w.color;
x.beginPath();
x.arc(
l.x-camera.x,
l.y-camera.y,
l.r,
0,
Math.PI*2
);
x.fill();
x.fillStyle="#111";
x.font="9px Arial";
x.textAlign="center";
x.fillText(
w.name,
l.x-camera.x,
l.y-camera.y+3
);
}
/* enemies */
for(let e of enemies){
if(e.dead)continue;
drawFOV(e);
drawActor(
e,
e.police?
"#3f82ff":
"#d94b4b"
);
}
/* player */
drawActor(player,"#57d97c");
/* bullets */
for(let b of bullets){
x.fillStyle=
b.enemy?
"#ff5d5d":
"#fff";
x.beginPath();
x.arc(
b.x-camera.x,
b.y-camera.y,
b.rad,
0,
Math.PI*2
);
x.fill();
}
/* hit particles */
for(let p of particles){
x.globalAlpha=clamp(p.life/400,0,1);
x.fillStyle="#fff";
x.fillRect(
p.x-camera.x,
p.y-camera.y,
3,
3
);
}
x.globalAlpha=1;
let a=currentAmmo(player);
let w=weapons[player.weapon];
document.getElementById("info").innerHTML=
`♥ ${Math.max(0,Math.ceil(player.hp))}
&nbsp;|&nbsp; ${w.name}
&nbsp;|&nbsp; ${a.clip}/${a.reserve}
&nbsp;|&nbsp; Wanted:
${"★".repeat(Math.ceil(wanted))}
${"☆".repeat(5-Math.ceil(wanted))}
&nbsp;|&nbsp; Q: switch
&nbsp;|&nbsp; R: reload
&nbsp;|&nbsp; E: loot`;
}
/* ---------------- GAME UPDATE ---------------- */
function update(dt){
control(dt);
for(let e of enemies)
if(!e.dead)
enemyAI(e,dt);
/* player/enemy collision */
for(let e of enemies){
if(e.dead)continue;
let dx=player.x-e.x;
let dy=player.y-e.y;
let d=Math.hypot(dx,dy);
if(d&&d<player.r+e.r){
let n=(player.r+e.r-d)/d;
move(player,dx*n,dy*n);
}
}
updateBullets(dt);
wantedSystem();
for(let p of particles){
p.x+=p.vx*dt;
p.y+=p.vy*dt;
p.life-=dt*1000;
}
particles=particles.filter(p=>p.life>0);
loot=loot.filter(l=>!l.dead);
enemies=enemies.filter(e=>!e.dead);
/* respawn */
if(player.hp<=0){
player.x=90;
player.y=700;
player.vx=player.vy=0;
player.hp=100;
wanted=0;
}
}
/* ---------------- LOOP ---------------- */
let last=performance.now();
function loop(t){
let dt=Math.min(.033,(t-last)/1000);
last=t;
update(dt);
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment